Compare commits

...
33 changed files with 5108 additions and 1023 deletions
+31
View File
@@ -0,0 +1,31 @@
# 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
(`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.
## Font Awesome Free
- Project: <https://fontawesome.com> · repository: <https://github.com/FortAwesome/Font-Awesome>
- Package: `@fortawesome/free-solid-svg-icons`
- Licence: <https://fontawesome.com/license/free>
- 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.
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
## Lucide
- Project: <https://lucide.dev> · repository: <https://github.com/lucide-icons/lucide>
- Package: `lucide-react`
- Licence: [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE)
- Use in NestQuest: a curated subset of Lucide icons, embedded as SVG path data.
Lucide icons are Copyright (c) Lucide Contributors, licensed under the ISC License.
Portions derived from Feather are Copyright (c) Cole Bemis, licensed under the MIT License.
+26 -1
View File
@@ -8,17 +8,18 @@
"name": "nestquest-admin",
"version": "0.0.0",
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@fortawesome/free-solid-svg-icons": "7.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"lucide-react": "1.47.0",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
@@ -982,6 +983,29 @@
}
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
"integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
"integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==",
"dev": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2262,6 +2286,7 @@
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+2 -1
View File
@@ -10,17 +10,18 @@
"test": "vitest run"
},
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@fortawesome/free-solid-svg-icons": "7.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"lucide-react": "1.47.0",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
+11
View File
@@ -583,3 +583,14 @@
color: var(--nq-a-ink-secondary);
font-style: italic;
}
.defs-sublabel {
margin-top: 4px;
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.defs-emoji {
line-height: 1;
}
+437 -7
View File
@@ -1,7 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import {
DEFINITION_ICONS,
DefinitionIcon,
FA_DEFINITION_ICONS,
emojiIconKey,
faIconName,
findDefinitionIcon,
normalizeDefinitionIconName,
} from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
@@ -153,6 +161,10 @@ function fakeApi(initial: QuestDefinition[]) {
}
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
if (method === "PATCH" && match) {
// Like the API's strict validate_icon: a Lucide name never has a colon.
if (typeof body.icon === "string" && /^lucide:.*:/.test(body.icon)) {
return jsonResponse({ detail: "icon is not a valid icon key" }, 422);
}
const id = Number(match[1]);
definitions = definitions.map((d) =>
d.id === id
@@ -455,7 +467,6 @@ describe("DefinitionsTab", () => {
path: "/api/v1/admin/quest-definitions/11",
body: {
title: "Take out the bins",
icon: null,
rule: {
rule_type: "monthly_day",
interval: 1,
@@ -1052,7 +1063,7 @@ describe("DefinitionsTab icon picker", () => {
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:bed");
expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
});
@@ -1073,13 +1084,13 @@ describe("DefinitionsTab icon picker", () => {
const { method, path, body } = api.writes()[0];
expect(method).toBe("PATCH");
expect(path).toBe("/api/v1/admin/quest-definitions/11");
expect((body as { icon: string }).icon).toBe("trash-2");
expect((body as { icon: string }).icon).toBe("lucide:trash-2");
// Inactive "Old" stays assigned: the unchanged selection is still omitted.
expect(body).not.toHaveProperty("assignee_child_ids");
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
});
it("an unchanged icon is still sent on edit and shows as selected", async () => {
it("an unchanged icon is omitted on edit and shows as selected", async () => {
await renderReady();
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
fireEvent.click(screen.getByTestId("definition-10"));
@@ -1093,7 +1104,7 @@ describe("DefinitionsTab icon picker", () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
@@ -1113,12 +1124,431 @@ describe("DefinitionsTab icon picker", () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
expect(api.writes()[0].body).not.toHaveProperty("icon");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
});
});
describe("DefinitionsTab namespaced icons", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function rowIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
expect(svg).not.toBeNull();
return svg!.getAttribute("data-icon");
}
function iconRadio(s: HTMLElement, label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
name: label,
});
}
it("normalizes lucide-namespaced and legacy bare values to one Lucide name", () => {
expect(normalizeDefinitionIconName("lucide:dog")).toBe("dog");
expect(normalizeDefinitionIconName("dog")).toBe("dog");
expect(normalizeDefinitionIconName("fa:dog")).toBeNull();
expect(normalizeDefinitionIconName("emoji:🐶")).toBeNull();
expect(normalizeDefinitionIconName("mystery:dog")).toBeNull();
expect(normalizeDefinitionIconName(null)).toBeNull();
});
for (const icon of ["lucide:dog", "dog"]) {
it(`renders and selects the Dog glyph for ${icon}`, async () => {
await renderReady([{ ...BRUSH, id: 30, icon }]);
const row = screen.getByTestId("definition-30");
expect(rowIcon(row)).toBe("dog");
fireEvent.click(row);
const s = sheet();
expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("true");
expect(iconRadio(s, "Dog").className).toContain("defs-icon--on");
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
});
}
for (const icon of ["mystery:dog", "lucide:not-an-icon", "fa:not-an-icon", "emoji:a b"]) {
it(`renders the fallback tile and selects nothing for ${icon}`, async () => {
await renderReady([{ ...BRUSH, id: 31, icon }]);
const row = screen.getByTestId("definition-31");
expect(rowIcon(row)).toBeNull();
fireEvent.click(row);
const s = sheet();
for (const name of ["Icon", "Font Awesome"]) {
const group = within(s).getByRole("radiogroup", { name });
for (const radio of within(group).getAllByRole("radio")) {
expect(radio.getAttribute("aria-checked")).toBe("false");
}
}
});
}
it("tapping a Lucide choice on a namespaced task PATCHes lucide:<name>, and No icon clears it", async () => {
await renderReady([{ ...BRUSH, id: 32, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-32"));
let s = sheet();
fireEvent.click(iconRadio(s, "Cat"));
expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true");
expect(iconRadio(s, "Dog").getAttribute("aria-checked")).toBe("false");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:cat");
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-32"))).toBe("cat"));
fireEvent.click(screen.getByTestId("definition-32"));
s = sheet();
expect(iconRadio(s, "Cat").getAttribute("aria-checked")).toBe("true");
fireEvent.click(iconRadio(s, "No icon"));
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
});
});
describe("DefinitionsTab Font Awesome and emoji icons", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
/** What a row draws: `svg:<data-icon>`, `emoji:<text>`, or `fallback`. */
function rowGlyph(row: HTMLElement): string {
const glyph = row.querySelector(".defs-row-glyph");
const emoji = glyph!.querySelector("[data-emoji]");
if (emoji) return `emoji:${emoji.textContent}`;
const svg = glyph!.querySelector("svg");
expect(svg).not.toBeNull();
const name = svg!.getAttribute("data-icon");
if (name === null) return "fallback";
return `${svg!.getAttribute("fill") === "none" ? "lucide" : "fa"}:${name}`;
}
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
name: label,
});
}
function emojiInput(s: HTMLElement): HTMLInputElement {
return within(s).getByRole("textbox", { name: "Emoji" }) as HTMLInputElement;
}
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("ships the curated Font Awesome subset, 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");
expect(findDefinitionIcon(`fa:${entry.name}`)).toBe(entry);
const { container, unmount } = render(<DefinitionIcon name={`fa:${entry.name}`} />);
const svg = container.querySelector("svg");
expect(svg, entry.name).not.toBeNull();
expect(svg!.getAttribute("data-icon")).toBe(entry.name);
expect(svg!.getAttribute("fill")).toBe("currentColor");
unmount();
}
});
it("builds and validates emoji keys like the registry", () => {
expect(emojiIconKey("🐶")).toBe("emoji:🐶");
expect(emojiIconKey("👨‍👩‍👧‍👦")).toBe("emoji:👨‍👩‍👧‍👦");
expect(emojiIconKey("⭐".repeat(8))).toBe(`emoji:${"⭐".repeat(8)}`);
for (const bad of ["", " ", " 🐶 ", "🐶 ", "\t🐶", "⭐".repeat(9), "a b", "<b>", "a&b", "🐶\u0007"]) {
expect(emojiIconKey(bad), JSON.stringify(bad)).toBeNull();
}
expect(faIconName("fa:dog")).toBe("dog");
expect(faIconName("lucide:dog")).toBeNull();
expect(faIconName(null)).toBeNull();
});
it("renders lucide, legacy, fa and emoji rows, and the fallback for none or unknown", async () => {
await renderReady([
{ ...BRUSH, id: 40, icon: "lucide:dog" },
{ ...BRUSH, id: 41, title: "Task 41", icon: "dog" },
{ ...BRUSH, id: 42, title: "Task 42", icon: "fa:dog" },
{ ...BRUSH, id: 43, title: "Task 43", icon: "emoji:🐶" },
{ ...BRUSH, id: 44, title: "Task 44", icon: null },
{ ...BRUSH, id: 45, title: "Task 45", icon: "" },
{ ...BRUSH, id: 46, title: "Task 46", icon: "mystery:dog" },
]);
expect(rowGlyph(screen.getByTestId("definition-40"))).toBe("lucide:dog");
expect(rowGlyph(screen.getByTestId("definition-41"))).toBe("lucide:dog");
expect(rowGlyph(screen.getByTestId("definition-42"))).toBe("fa:dog");
expect(rowGlyph(screen.getByTestId("definition-43"))).toBe("emoji:🐶");
expect(rowGlyph(screen.getByTestId("definition-44"))).toBe("fallback");
expect(rowGlyph(screen.getByTestId("definition-45"))).toBe("fallback");
expect(rowGlyph(screen.getByTestId("definition-46"))).toBe("fallback");
});
it("a Font Awesome choice is POSTed as fa:<name> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.click(radio(s, "Font Awesome", "Dog"));
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
expect(radio(s, "Font Awesome", "Dog").className).toContain("defs-icon--on");
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dog");
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("fa:dog");
});
it("a typed emoji is POSTed as emoji:<grapheme> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:🐶");
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("emoji:🐶");
});
it("a Lucide choice is POSTed as lucide:<name> after an emoji was typed", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
fireEvent.click(radio(s, "Icon", "Dog"));
expect(emojiInput(s).value).toBe("");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:dog");
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("lucide:dog");
});
for (const bad of [" 🐶 ", "🐶 ", " ", "a b", "<b>", "a&b", "⭐".repeat(9)]) {
it(`an invalid emoji entry ${JSON.stringify(bad)} is not saved`, async () => {
await renderReady([{ ...BRUSH, id: 50, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-50"));
const s = sheet();
fireEvent.change(emojiInput(s), { target: { value: bad } });
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
expect(screen.getByRole("dialog")).toBe(s);
});
}
it("prefills a fa:<name> task and PATCHes an emoji replacement", async () => {
await renderReady([{ ...BRUSH, id: 51, icon: "fa:dog" }]);
fireEvent.click(screen.getByTestId("definition-51"));
const s = sheet();
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
expect(emojiInput(s).value).toBe("");
fireEvent.change(emojiInput(s), { target: { value: "🐱" } });
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
await save(s);
const { method, path, body } = api.writes()[0];
expect(method).toBe("PATCH");
expect(path).toBe("/api/v1/admin/quest-definitions/51");
expect((body as { icon: string }).icon).toBe("emoji:🐱");
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-51"))).toBe("emoji:🐱"));
});
it("prefills an emoji:<grapheme> task and PATCHes a Font Awesome replacement", async () => {
await renderReady([{ ...BRUSH, id: 52, icon: "emoji:🐶" }]);
fireEvent.click(screen.getByTestId("definition-52"));
const s = sheet();
expect(emojiInput(s).value).toBe("🐶");
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
for (const name of ["Icon", "Font Awesome"]) {
const group = within(s).getByRole("radiogroup", { name });
expect(within(group).queryAllByRole("radio", { checked: true })).toHaveLength(0);
}
fireEvent.click(radio(s, "Font Awesome", "Pets"));
expect(emojiInput(s).value).toBe("");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:paw");
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-52"))).toBe("fa:paw"));
});
it("an unchanged emoji is omitted on edit", async () => {
await renderReady([{ ...BRUSH, id: 53, icon: "emoji:🐶" }]);
fireEvent.click(screen.getByTestId("definition-53"));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the dog" },
});
await save(s);
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("No icon and an emptied emoji field both clear the icon to null", async () => {
await renderReady([
{ ...BRUSH, id: 54, icon: "emoji:🐶" },
{ ...BRUSH, id: 55, title: "Task 55", icon: "fa:dog" },
]);
fireEvent.click(screen.getByTestId("definition-54"));
let s = sheet();
fireEvent.change(emojiInput(s), { target: { value: "" } });
expect(radio(s, "Icon", "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-55"));
s = sheet();
fireEvent.click(radio(s, "Icon", "No icon"));
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
await save(s);
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-55"))).toBe("fallback"));
});
});
/* ── Legacy icons ───────────────────────────────────────────────────── */
describe("DefinitionsTab legacy icon edits", () => {
// The API reads a stored legacy `mdi:tooth` back as `lucide:mdi:tooth`,
// which its strict write validation rejects if sent back.
const LEGACY: QuestDefinition = { ...BRUSH, id: 60, icon: "lucide:mdi:tooth" };
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
name: label,
});
}
async function save(s: HTMLElement) {
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
}
it("editing only the title omits the legacy icon and saves", async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByTestId("definition-60"));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Brush teeth well" },
});
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("PATCH");
expect(api.writes()[0].body).not.toHaveProperty("icon");
expect((api.writes()[0].body as { title: string }).title).toBe("Brush teeth well");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(await screen.findByText("Brush teeth well")).toBeTruthy();
});
for (const [label, choose, sent] of [
["a Lucide", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "Dog")), "lucide:dog"],
["a Font Awesome", (s: HTMLElement) => fireEvent.click(radio(s, "Font Awesome", "Dog")), "fa:dog"],
[
"an emoji",
(s: HTMLElement) =>
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
target: { value: "🦷" },
}),
"emoji:🦷",
],
["No icon", (s: HTMLElement) => fireEvent.click(radio(s, "Icon", "No icon")), null],
] as const) {
it(`choosing ${label} replacement for the legacy icon sends ${JSON.stringify(sent)}`, async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByTestId("definition-60"));
const s = sheet();
choose(s);
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].body).toHaveProperty("icon", sent);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
}
it("an invalid emoji replacing the legacy icon is still rejected before the request", async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByTestId("definition-60"));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Emoji" }), {
target: { value: "a b" },
});
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
});
it("creating a task still sends icon, even when none is chosen", async () => {
await renderReady([LEGACY]);
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Floss" },
});
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" }));
await save(s);
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect(api.writes()[0].body).toHaveProperty("icon", null);
});
});
/* ── Start date ─────────────────────────────────────────────────────── */
function startDateInput(s: HTMLElement): HTMLInputElement {
+84 -19
View File
@@ -43,7 +43,18 @@ import {
PlusGlyph,
TaskGlyph,
} from "./glyphs";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import {
DEFINITION_ICONS,
DefinitionIcon,
FA_DEFINITION_ICONS,
emojiIconKey,
emojiIconText,
faIconKey,
faIconName,
lucideIconKey,
normalizeDefinitionIconName,
} from "./definitionIcons";
import { GlyphSvg } from "../icons/GlyphSvg";
import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -78,8 +89,13 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
interface Draft {
title: string;
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
/**
* The stored icon key (`lucide:`, `fa:` or `emoji:`); a legacy value
* outside the curated sets is kept until replaced.
*/
icon: string | null;
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
emoji: string;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -120,6 +136,7 @@ function newDraft(activeChildren: AdminChild[]): Draft {
return {
title: "",
icon: null,
emoji: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
@@ -145,6 +162,7 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
return {
title: definition.title,
icon: definition.icon,
emoji: emojiIconText(definition.icon),
// 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],
@@ -231,6 +249,9 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
if (!title) return "Give the task a title.";
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
if (draft.windows.length === 0) return "Pick at least one window.";
if (draft.emoji && draft.icon === null) {
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
}
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
@@ -441,6 +462,10 @@ function EditSheet({
// assignee_child_ids, because the API replaces the whole set and inactive
// children cannot be sent back.
const [originalActiveIds] = useState(() => draft.assigneeIds);
// The icon as loaded: while it is unchanged, the PATCH omits `icon` (absent
// means unchanged), because a legacy value read back as `lucide:<old>` would
// fail the API's strict icon validation if sent back.
const [originalIcon] = useState(() => draft.icon);
const inactiveAssignees =
target === "new"
? []
@@ -523,6 +548,7 @@ function EditSheet({
} else {
const edit: DefinitionEditBody = { ...body };
if (keepsAssignees) delete edit.assignee_child_ids;
if (draft.icon === originalIcon) delete edit.icon;
await updateDefinition(target.id, edit);
}
onSaved();
@@ -569,28 +595,67 @@ function EditSheet({
<button
type="button"
role="radio"
aria-checked={draft.icon === null}
aria-checked={draft.icon === null && !draft.emoji}
aria-label="No icon"
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null })}
className={
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
}
onClick={() => update({ icon: null, emoji: "" })}
>
<TaskGlyph size={18} />
</button>
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
<button
key={name}
type="button"
role="radio"
aria-checked={draft.icon === name}
aria-label={label}
data-icon-option={name}
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: name })}
>
<Icon size={18} aria-hidden="true" focusable="false" />
</button>
))}
{DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = normalizeDefinitionIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
<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: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
<label className="defs-due">
<span>Emoji</span>
<input
className="defs-input"
aria-label="Emoji"
placeholder="e.g. 🐶"
value={draft.emoji}
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
/>
</label>
</Field>
<Field label="Assigned children" id="defs-label-children">
+80 -93
View File
@@ -1,103 +1,90 @@
/**
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
* 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.
*/
import {
Apple,
Backpack,
Bath,
Bed,
Bike,
BookOpen,
Brush,
BrushCleaning,
Calendar,
Cat,
Clock,
CookingPot,
Dog,
Droplets,
Fish,
Gamepad2,
Heart,
House,
ListChecks,
Moon,
Music,
Pencil,
Recycle,
Shirt,
ShowerHead,
Smile,
Sofa,
SprayCan,
Sprout,
Star,
Sun,
Toilet,
Trash2,
Utensils,
Volleyball,
WashingMachine,
type LucideIcon,
} from "lucide-react";
import { GlyphSvg } from "../icons/GlyphSvg";
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs";
export interface DefinitionIconEntry {
name: string;
label: string;
Icon: LucideIcon;
export type DefinitionIconEntry = GlyphIcon;
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS;
export const FA_DEFINITION_ICONS: readonly DefinitionIconEntry[] = FA_ICONS;
const LUCIDE_PREFIX = "lucide:";
const FA_PREFIX = "fa:";
const EMOJI_PREFIX = "emoji:";
/** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */
export function lucideIconKey(name: string): string {
return `${LUCIDE_PREFIX}${name}`;
}
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
{ name: "bed", label: "Bed", Icon: Bed },
{ name: "bath", label: "Bath", Icon: Bath },
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
{ name: "toilet", label: "Toilet", Icon: Toilet },
{ name: "brush", label: "Brush", Icon: Brush },
{ name: "smile", label: "Smile", Icon: Smile },
{ name: "shirt", label: "Clothes", Icon: Shirt },
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
{ name: "utensils", label: "Meal", Icon: Utensils },
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
{ name: "apple", label: "Snack", Icon: Apple },
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
{ name: "spray-can", label: "Clean", Icon: SprayCan },
{ name: "trash-2", label: "Bins", Icon: Trash2 },
{ name: "recycle", label: "Recycling", Icon: Recycle },
{ name: "sofa", label: "Tidy", Icon: Sofa },
{ name: "house", label: "Home", Icon: House },
{ name: "backpack", label: "School bag", Icon: Backpack },
{ name: "book-open", label: "Reading", Icon: BookOpen },
{ name: "pencil", label: "Homework", Icon: Pencil },
{ name: "music", label: "Music", Icon: Music },
{ name: "dog", label: "Dog", Icon: Dog },
{ name: "cat", label: "Cat", Icon: Cat },
{ name: "fish", label: "Fish", Icon: Fish },
{ name: "sprout", label: "Plants", Icon: Sprout },
{ name: "droplets", label: "Water", Icon: Droplets },
{ name: "bike", label: "Bike", Icon: Bike },
{ name: "volleyball", label: "Sport", Icon: Volleyball },
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
{ name: "star", label: "Star", Icon: Star },
{ name: "heart", label: "Heart", Icon: Heart },
{ name: "sun", label: "Morning", Icon: Sun },
{ name: "moon", label: "Bedtime", Icon: Moon },
{ name: "clock", label: "Clock", Icon: Clock },
{ name: "calendar", label: "Calendar", Icon: Calendar },
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
];
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
return name === null ? null : (BY_NAME.get(name) ?? null);
/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */
export function faIconKey(name: string): string {
return `${FA_PREFIX}${name}`;
}
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
/**
* The stored key for typed emoji text, e.g. `emoji:🐶`. The raw text — not
* trimmed, as the backend rejects any whitespace — must pass the registry's
* emoji check (1-8 code points, no whitespace, control characters or `<`,
* `>`, `&`), else null. Only an empty field means "no icon".
*/
export function emojiIconKey(text: string): string | null {
const key = `${EMOJI_PREFIX}${text}`;
return resolveIcon(key).kind === "emoji" ? key : null;
}
/**
* The Lucide name an icon value refers to: `lucide:dog` and a legacy bare
* `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and
* null give null — the admin has no Lucide entry for them.
*/
export function normalizeDefinitionIconName(value: string | null): string | null {
if (value === null) return null;
if (value.startsWith(LUCIDE_PREFIX)) return value.slice(LUCIDE_PREFIX.length);
return value.includes(":") ? null : value;
}
/** The Font Awesome name an `fa:<name>` value refers to; anything else gives null. */
export function faIconName(value: string | null): string | null {
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
}
/** 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.
*/
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
const icon = resolveIcon(value);
return icon.kind === "lucide" || icon.kind === "fa" ? icon : null;
}
/**
* A definition's icon: a Lucide or Font Awesome glyph, an emoji drawn as
* text, or — for no icon or an unknown value — the generic task glyph.
*/
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
const entry = findDefinitionIcon(name);
if (!entry) return <TaskGlyph size={size} />;
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
const icon = resolveIcon(name);
if (icon.kind === "fallback") return <TaskGlyph size={size} />;
if (icon.kind === "emoji") {
return (
<span className="defs-emoji" style={{ fontSize: size }} aria-hidden="true" data-emoji={icon.text}>
{icon.text}
</span>
);
}
return <GlyphSvg icon={icon} size={size} />;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Draws a registry glyph (`registry.generated.ts`) as an inline SVG — the
* path data is bundled, so there is no CDN and no runtime fetch. Lucide
* glyphs are stroked on their 24px grid; Font Awesome glyphs are filled.
*/
import type { GlyphIcon } from "./registry.generated";
export function GlyphSvg({ icon, size }: { icon: GlyphIcon; size: number }) {
const stroked = icon.kind === "lucide";
return (
<svg
width={size}
height={size}
viewBox={icon.viewBox}
fill={stroked ? "none" : "currentColor"}
stroke={stroked ? "currentColor" : undefined}
strokeWidth={stroked ? 2 : undefined}
strokeLinecap={stroked ? "round" : undefined}
strokeLinejoin={stroked ? "round" : undefined}
aria-hidden="true"
focusable="false"
data-icon={icon.name}
>
{icon.paths.map((d, index) => (
<path key={index} d={d} />
))}
</svg>
);
}
+765
View File
@@ -0,0 +1,765 @@
// GENERATED by tools/icons/generate.mjs from tools/icons/curated-icons.json — do not edit.
// Regenerate with `node tools/icons/generate.mjs` (the drift tests fail if this
// file differs from a fresh generation).
//
// Glyph path data is embedded at generation time; nothing is fetched at runtime.
// Lucide glyphs: lucide-react 1.47.0 (ISC licence).
// Font Awesome glyphs: @fortawesome/free-solid-svg-icons 7.3.1
// (Font Awesome Free, icons under CC BY 4.0).
/** A vector glyph: draw each path in `paths` inside an <svg viewBox>. Lucide
* glyphs are stroked (fill none, stroke-width 2, round caps and joins);
* Font Awesome glyphs are filled. */
export interface GlyphIcon {
kind: "lucide" | "fa";
key: string;
name: string;
label: string;
viewBox: string;
paths: readonly string[];
}
/** An emoji icon; render `text` as text. */
export interface EmojiIcon {
kind: "emoji";
key: string;
text: string;
}
/** No usable icon: the caller draws its own default glyph. */
export interface FallbackIcon {
kind: "fallback";
key: null;
}
export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
export const LUCIDE_ICONS: readonly GlyphIcon[] = [
{
"kind": "lucide",
"key": "lucide:bed",
"name": "bed",
"label": "Bed",
"viewBox": "0 0 24 24",
"paths": [
"M2 4v16",
"M2 8h18a2 2 0 0 1 2 2v10",
"M2 17h20",
"M6 8v9"
]
},
{
"kind": "lucide",
"key": "lucide:bath",
"name": "bath",
"label": "Bath",
"viewBox": "0 0 24 24",
"paths": [
"M10 4 8 6",
"M17 19v2",
"M2 12h20",
"M7 19v2",
"M9 5 7.621 3.621A2.121 2.121 0 0 0 4 5v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"
]
},
{
"kind": "lucide",
"key": "lucide:shower-head",
"name": "shower-head",
"label": "Shower",
"viewBox": "0 0 24 24",
"paths": [
"m4 4 2.5 2.5",
"M13.5 6.5a4.95 4.95 0 0 0-7 7",
"M15 5 5 15",
"M14 17v.01",
"M10 16v.01",
"M13 13v.01",
"M16 10v.01",
"M11 20v.01",
"M17 14v.01",
"M20 11v.01"
]
},
{
"kind": "lucide",
"key": "lucide:toilet",
"name": "toilet",
"label": "Toilet",
"viewBox": "0 0 24 24",
"paths": [
"M7 12h13a1 1 0 0 1 1 1 5 5 0 0 1-5 5h-.598a.5.5 0 0 0-.424.765l1.544 2.47a.5.5 0 0 1-.424.765H5.402a.5.5 0 0 1-.424-.765L7 18",
"M8 18a5 5 0 0 1-5-5V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8"
]
},
{
"kind": "lucide",
"key": "lucide:brush",
"name": "brush",
"label": "Brush",
"viewBox": "0 0 24 24",
"paths": [
"m11 10 3 3",
"M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z",
"M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
]
},
{
"kind": "lucide",
"key": "lucide:smile",
"name": "smile",
"label": "Smile",
"viewBox": "0 0 24 24",
"paths": [
"M15 10V9",
"M16.472 15a6 6 0 01-8.943 0",
"M9 10V9",
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0"
]
},
{
"kind": "lucide",
"key": "lucide:shirt",
"name": "shirt",
"label": "Clothes",
"viewBox": "0 0 24 24",
"paths": [
"M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"
]
},
{
"kind": "lucide",
"key": "lucide:washing-machine",
"name": "washing-machine",
"label": "Laundry",
"viewBox": "0 0 24 24",
"paths": [
"M3 6h3",
"M17 6h.01",
"M5 2h14a2 2 0 0 1 2 2v16a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-16a2 2 0 0 1 2 -2z",
"M7 13a5 5 0 1 0 10 0a5 5 0 1 0 -10 0",
"M12 18a2.5 2.5 0 0 0 0-5 2.5 2.5 0 0 1 0-5"
]
},
{
"kind": "lucide",
"key": "lucide:utensils",
"name": "utensils",
"label": "Meal",
"viewBox": "0 0 24 24",
"paths": [
"M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2",
"M7 2v20",
"M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"
]
},
{
"kind": "lucide",
"key": "lucide:cooking-pot",
"name": "cooking-pot",
"label": "Cooking",
"viewBox": "0 0 24 24",
"paths": [
"M2 12h20",
"M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8",
"m4 8 16-4",
"m8.86 6.78-.45-1.81a2 2 0 0 1 1.45-2.43l1.94-.48a2 2 0 0 1 2.43 1.46l.45 1.8"
]
},
{
"kind": "lucide",
"key": "lucide:apple",
"name": "apple",
"label": "Snack",
"viewBox": "0 0 24 24",
"paths": [
"M12 6.528V3a1 1 0 0 1 1-1h0",
"M18.237 21A15 15 0 0 0 22 11a6 6 0 0 0-10-4.472A6 6 0 0 0 2 11a15.1 15.1 0 0 0 3.763 10 3 3 0 0 0 3.648.648 5.5 5.5 0 0 1 5.178 0A3 3 0 0 0 18.237 21"
]
},
{
"kind": "lucide",
"key": "lucide:brush-cleaning",
"name": "brush-cleaning",
"label": "Sweep",
"viewBox": "0 0 24 24",
"paths": [
"m16 22-1-4",
"M19 14a1 1 0 0 0 1-1v-1a2 2 0 0 0-2-2h-3a1 1 0 0 1-1-1V4a2 2 0 0 0-4 0v5a1 1 0 0 1-1 1H6a2 2 0 0 0-2 2v1a1 1 0 0 0 1 1",
"M19 14H5l-1.973 6.767A1 1 0 0 0 4 22h16a1 1 0 0 0 .973-1.233z",
"m8 22 1-4"
]
},
{
"kind": "lucide",
"key": "lucide:spray-can",
"name": "spray-can",
"label": "Clean",
"viewBox": "0 0 24 24",
"paths": [
"M3 3h.01",
"M7 5h.01",
"M11 7h.01",
"M3 7h.01",
"M7 9h.01",
"M3 11h.01",
"M15 5h4v4h-4z",
"m19 9 2 2v10c0 .6-.4 1-1 1h-6c-.6 0-1-.4-1-1V11l2-2",
"m13 14 8-2",
"m13 19 8-2"
]
},
{
"kind": "lucide",
"key": "lucide:trash-2",
"name": "trash-2",
"label": "Bins",
"viewBox": "0 0 24 24",
"paths": [
"M10 11v6",
"M14 11v6",
"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",
"M3 6h18",
"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
]
},
{
"kind": "lucide",
"key": "lucide:recycle",
"name": "recycle",
"label": "Recycling",
"viewBox": "0 0 24 24",
"paths": [
"M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5",
"M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12",
"m14 16-3 3 3 3",
"M8.293 13.596 7.196 9.5 3.1 10.598",
"m9.344 5.811 1.093-1.892A1.83 1.83 0 0 1 11.985 3a1.784 1.784 0 0 1 1.546.888l3.943 6.843",
"m13.378 9.633 4.096 1.098 1.097-4.096"
]
},
{
"kind": "lucide",
"key": "lucide:sofa",
"name": "sofa",
"label": "Tidy",
"viewBox": "0 0 24 24",
"paths": [
"M20 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3",
"M2 16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z",
"M4 18v2",
"M20 18v2",
"M12 4v9"
]
},
{
"kind": "lucide",
"key": "lucide:house",
"name": "house",
"label": "Home",
"viewBox": "0 0 24 24",
"paths": [
"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8",
"M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
]
},
{
"kind": "lucide",
"key": "lucide:backpack",
"name": "backpack",
"label": "School bag",
"viewBox": "0 0 24 24",
"paths": [
"M4 10a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z",
"M8 10h8",
"M8 18h8",
"M8 22v-6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v6",
"M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"
]
},
{
"kind": "lucide",
"key": "lucide:book-open",
"name": "book-open",
"label": "Reading",
"viewBox": "0 0 24 24",
"paths": [
"M12 5v16",
"M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z"
]
},
{
"kind": "lucide",
"key": "lucide:pencil",
"name": "pencil",
"label": "Homework",
"viewBox": "0 0 24 24",
"paths": [
"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",
"m15 5 4 4"
]
},
{
"kind": "lucide",
"key": "lucide:music",
"name": "music",
"label": "Music",
"viewBox": "0 0 24 24",
"paths": [
"M9 18V5l12-2v13",
"M3 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0",
"M15 16a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"
]
},
{
"kind": "lucide",
"key": "lucide:dog",
"name": "dog",
"label": "Dog",
"viewBox": "0 0 24 24",
"paths": [
"M11.25 16.25h1.5L12 17z",
"M16 14v.5",
"M4.42 11.247A13.152 13.152 0 0 0 4 14.556C4 18.728 7.582 21 12 21s8-2.272 8-6.444a11.702 11.702 0 0 0-.493-3.309",
"M8 14v.5",
"M8.5 8.5c-.384 1.05-1.083 2.028-2.344 2.5-1.931.722-3.576-.297-3.656-1-.113-.994 1.177-6.53 4-7 1.923-.321 3.651.845 3.651 2.235A7.497 7.497 0 0 1 14 5.277c0-1.39 1.844-2.598 3.767-2.277 2.823.47 4.113 6.006 4 7-.08.703-1.725 1.722-3.656 1-1.261-.472-1.855-1.45-2.239-2.5"
]
},
{
"kind": "lucide",
"key": "lucide:cat",
"name": "cat",
"label": "Cat",
"viewBox": "0 0 24 24",
"paths": [
"M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5Z",
"M8 14v.5",
"M16 14v.5",
"M11.25 16.25h1.5L12 17l-.75-.75Z"
]
},
{
"kind": "lucide",
"key": "lucide:fish",
"name": "fish",
"label": "Fish",
"viewBox": "0 0 24 24",
"paths": [
"M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6Z",
"M18 12v.5",
"M16 17.93a9.77 9.77 0 0 1 0-11.86",
"M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 1.5-1 5 .23 6.5-1.24 1.5-1.24 5-.23 6.5C5.58 18.03 7 16 7 13.33",
"M10.46 7.26C10.2 5.88 9.17 4.24 8 3h5.8a2 2 0 0 1 1.98 1.67l.23 1.4",
"m16.01 17.93-.23 1.4A2 2 0 0 1 13.8 21H9.5a5.96 5.96 0 0 0 1.49-3.98"
]
},
{
"kind": "lucide",
"key": "lucide:sprout",
"name": "sprout",
"label": "Plants",
"viewBox": "0 0 24 24",
"paths": [
"M14 9.536V7a4 4 0 0 1 4-4h1.5a.5.5 0 0 1 .5.5V5a4 4 0 0 1-4 4 4 4 0 0 0-4 4c0 2 1 3 1 5a5 5 0 0 1-1 3",
"M4 9a5 5 0 0 1 8 4 5 5 0 0 1-8-4",
"M5 21h14"
]
},
{
"kind": "lucide",
"key": "lucide:droplets",
"name": "droplets",
"label": "Water",
"viewBox": "0 0 24 24",
"paths": [
"M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z",
"M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"
]
},
{
"kind": "lucide",
"key": "lucide:bike",
"name": "bike",
"label": "Bike",
"viewBox": "0 0 24 24",
"paths": [
"M15 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0",
"M2 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0",
"M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0",
"M12 17.5V14l-3-3 4-3 2 3h2"
]
},
{
"kind": "lucide",
"key": "lucide:volleyball",
"name": "volleyball",
"label": "Sport",
"viewBox": "0 0 24 24",
"paths": [
"M11 7a16 16 20 0 1 10.98 4.362",
"M12 12a13 13 0 0 1-8.66 5",
"M16.83 13.634a16 16 0 0 1-9.267 7.328",
"M20.66 17A13 13 0 0 0 12 12a13 13 0 0 1 0-10",
"M8.17 15.366a16 16 0 0 1-1.713-11.69",
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0"
]
},
{
"kind": "lucide",
"key": "lucide:gamepad-2",
"name": "gamepad-2",
"label": "Games",
"viewBox": "0 0 24 24",
"paths": [
"M6 11L10 11",
"M8 9L8 13",
"M15 12L15.01 12",
"M18 10L18.01 10",
"M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"
]
},
{
"kind": "lucide",
"key": "lucide:star",
"name": "star",
"label": "Star",
"viewBox": "0 0 24 24",
"paths": [
"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
]
},
{
"kind": "lucide",
"key": "lucide:heart",
"name": "heart",
"label": "Heart",
"viewBox": "0 0 24 24",
"paths": [
"M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"
]
},
{
"kind": "lucide",
"key": "lucide:sun",
"name": "sun",
"label": "Morning",
"viewBox": "0 0 24 24",
"paths": [
"M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0",
"M12 2v2",
"M12 20v2",
"m4.93 4.93 1.41 1.41",
"m17.66 17.66 1.41 1.41",
"M2 12h2",
"M20 12h2",
"m6.34 17.66-1.41 1.41",
"m19.07 4.93-1.41 1.41"
]
},
{
"kind": "lucide",
"key": "lucide:moon",
"name": "moon",
"label": "Bedtime",
"viewBox": "0 0 24 24",
"paths": [
"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
]
},
{
"kind": "lucide",
"key": "lucide:clock",
"name": "clock",
"label": "Clock",
"viewBox": "0 0 24 24",
"paths": [
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0",
"M12 6v6l4 2"
]
},
{
"kind": "lucide",
"key": "lucide:calendar",
"name": "calendar",
"label": "Calendar",
"viewBox": "0 0 24 24",
"paths": [
"M8 2v3",
"M16 2v3",
"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2z",
"M3 9h18"
]
},
{
"kind": "lucide",
"key": "lucide:list-checks",
"name": "list-checks",
"label": "Checklist",
"viewBox": "0 0 24 24",
"paths": [
"M13 5h8",
"M13 12h8",
"M13 19h8",
"m3 17 2 2 4-4",
"m3 7 2 2 4-4"
]
}
];
export const FA_ICONS: readonly GlyphIcon[] = [
{
"kind": "fa",
"key": "fa:broom",
"name": "broom",
"label": "Sweep",
"viewBox": "0 0 576 512",
"paths": [
"M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6-12.5 0-22.6 10.1-22.6 22.6l0 29.1 108.3 108.3 29.1 0c12.5 0 22.6-10.1 22.6-22.6 0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8c-22.3 22.3-34.8 52.5-34.8 84 0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9 0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8 30.6-30.6 45.9-73.1 42.3-115.8z"
]
},
{
"kind": "fa",
"key": "fa:spray-can-sparkles",
"name": "spray-can-sparkles",
"label": "Clean",
"viewBox": "0 0 576 512",
"paths": [
"M160 32l0 80 128 0 0-80c0-17.7-14.3-32-32-32L192 0c-17.7 0-32 14.3-32 32zm0 128c-53 0-96 43-96 96l0 208c0 26.5 21.5 48 48 48l224 0c26.5 0 48-21.5 48-48l0-208c0-53-43-96-96-96l-128 0zm64 96a80 80 0 1 1 0 160 80 80 0 1 1 0-160zM448 48c0-1.4-1-3-2.2-3.6L416 32 403.6 2.2C403 1 401.4 0 400 0s-3 1-3.6 2.2L384 32 354.2 44.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L384 64 396.4 93.8C397 95 398.6 96 400 96s3-1 3.6-2.2L416 64 445.8 51.6C447 51 448 49.4 448 48zm76.4 45.8C525 95 526.6 96 528 96s3-1 3.6-2.2L544 64 573.8 51.6c1.2-.6 2.2-2.2 2.2-3.6 0-1.4-1-3-2.2-3.6L544 32 531.6 2.2C531 1 529.4 0 528 0s-3 1-3.6 2.2L512 32 482.2 44.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L512 64 524.4 93.8zm7.2 100.4c-.6-1.2-2.2-2.2-3.6-2.2s-3 1-3.6 2.2L512 224 482.2 236.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L512 256 524.4 285.8c.6 1.2 2.2 2.2 3.6 2.2s3-1 3.6-2.2L544 256 573.8 243.6c1.2-.6 2.2-2.2 2.2-3.6 0-1.4-1-3-2.2-3.6L544 224 531.6 194.2zM512 144c0-1.4-1-3-2.2-3.6L480 128 467.6 98.2C467 97 465.4 96 464 96s-3 1-3.6 2.2L448 128 418.2 140.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L448 160 460.4 189.8c.6 1.2 2.2 2.2 3.6 2.2s3-1 3.6-2.2L480 160 509.8 147.6c1.2-.6 2.2-2.2 2.2-3.6z"
]
},
{
"kind": "fa",
"key": "fa:bucket",
"name": "bucket",
"label": "Mop",
"viewBox": "0 0 448 512",
"paths": [
"M443.7 208c2.7 4.7 4.3 10.2 4.3 16 0 17.7-14.3 32-32 32l-5.1 0-22.4 213c-2.6 24.4-23.2 43-47.8 43l-233.6 0c-24.6 0-45.2-18.5-47.8-43L37.1 256 32 256c-17.7 0-32-14.3-32-32 0-5.8 1.6-11.3 4.3-16l439.4 0zM224-16c79.5 0 144 64.5 144 144l0 32-48 0 0-32c0-53-43-96-96-96s-96 43-96 96l0 32-48 0 0-32C80 48.5 144.5-16 224-16z"
]
},
{
"kind": "fa",
"key": "fa:soap",
"name": "soap",
"label": "Wash hands",
"viewBox": "0 0 512 512",
"paths": [
"M208-32a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM320 96a64 64 0 1 1 0 128 64 64 0 1 1 0-128zM352 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM96 160l112 0c0 23.8 7.4 45.9 20.1 64L160 224c-53 0-96 43-96 96s43 96 96 96l192 0c53 0 96-43 96-96 0-35.1-18.9-65.9-47-82.6 19-19.8 30.7-46.6 31-76.1 45.4 7.6 80 47.1 80 94.7l0 128c0 53-43 96-96 96L96 480c-53 0-96-43-96-96L0 256c0-53 43-96 96-96zm64 112l192 0c26.5 0 48 21.5 48 48s-21.5 48-48 48l-192 0c-26.5 0-48-21.5-48-48s21.5-48 48-48z"
]
},
{
"kind": "fa",
"key": "fa:sink",
"name": "sink",
"label": "Dishes",
"viewBox": "0 0 512 512",
"paths": [
"M288 96c0-17.7 14.3-32 32-32s32 14.3 32 32 14.3 32 32 32 32-14.3 32-32c0-53-43-96-96-96s-96 43-96 96l0 192-64 0 0-40c0-30.9-25.1-56-56-56l-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l48 0c4.4 0 8 3.6 8 8l0 40-80 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 64c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-64c17.7 0 32-14.3 32-32s-14.3-32-32-32l-80 0 0-40c0-4.4 3.6-8 8-8l56 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-56 0c-30.9 0-56 25.1-56 56l0 40-64 0 0-192z"
]
},
{
"kind": "fa",
"key": "fa:jug-detergent",
"name": "jug-detergent",
"label": "Laundry",
"viewBox": "0 0 384 512",
"paths": [
"M96 24c0-13.3 10.7-24 24-24l80 0c13.3 0 24 10.7 24 24l0 24 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L88 96C74.7 96 64 85.3 64 72S74.7 48 88 48l8 0 0-24zM0 256c0-70.7 57.3-128 128-128l128 0c70.7 0 128 57.3 128 128l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256zm256 0l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32z"
]
},
{
"kind": "fa",
"key": "fa:socks",
"name": "socks",
"label": "Socks",
"viewBox": "0 0 512 512",
"paths": [
"M252.8 0L176 0c-26.5 0-48 21.5-48 48l0 16 112 0 0-16c0-17.5 4.7-33.9 12.8-48zM128 112l0 128c0 20.1-9.5 39.1-25.6 51.2l-64 48c-24.2 18.1-38.4 46.6-38.4 76.8 0 53 43 96 96 96 15.4 0 30.5-3.7 44-10.7-17.6-23.9-28-53.4-28-85.3 0-45.3 21.3-88 57.6-115.2l64-48c4-3 6.4-7.8 6.4-12.8l0-128-112 0zm160 0l0 128c0 20.1-9.5 39.1-25.6 51.2l-64 48c-24.2 18.1-38.4 46.6-38.4 76.8 0 53 43 96 96 96 20.8 0 41-6.7 57.6-19.2l115.2-86.4C461 382.2 480 344.3 480 304l0-192-192 0zM480 64l0-16c0-26.5-21.5-48-48-48L336 0c-26.5 0-48 21.5-48 48l0 16 192 0z"
]
},
{
"kind": "fa",
"key": "fa:shirt",
"name": "shirt",
"label": "Clothes",
"viewBox": "0 0 640 512",
"paths": [
"M320.2 112c44.2 0 80-35.8 80-80l53.5 0c17 0 33.3 6.7 45.3 18.7L617.6 169.4c12.5 12.5 12.5 32.8 0 45.3l-50.7 50.7c-12.5 12.5-32.8 12.5-45.3 0l-41.4-41.4 0 224c0 35.3-28.7 64-64 64l-192 0c-35.3 0-64-28.7-64-64l0-224-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0L22.9 214.6c-12.5-12.5-12.5-32.8 0-45.3L141.5 50.7c12-12 28.3-18.7 45.3-18.7l53.5 0c0 44.2 35.8 80 80 80z"
]
},
{
"kind": "fa",
"key": "fa:bed",
"name": "bed",
"label": "Bed",
"viewBox": "0 0 576 512",
"paths": [
"M32 32c17.7 0 32 14.3 32 32l0 224 224 0 0-128c0-17.7 14.3-32 32-32l160 0c53 0 96 43 96 96l0 224c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64-448 0 0 64c0 17.7-14.3 32-32 32S0 465.7 0 448L0 64C0 46.3 14.3 32 32 32zm80 160a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"
]
},
{
"kind": "fa",
"key": "fa:couch",
"name": "couch",
"label": "Tidy",
"viewBox": "0 0 640 512",
"paths": [
"M144 272C144 224.7 109.8 185.4 64.8 177.5 72 113.6 126.2 64 192 64l256 0c65.8 0 120 49.6 127.2 113.5-45 8-79.2 47.2-79.2 94.5l0 32-352 0 0-32zM0 384L0 272c0-26.5 21.5-48 48-48s48 21.5 48 48l0 80 448 0 0-80c0-26.5 21.5-48 48-48s48 21.5 48 48l0 112c0 35.3-28.7 64-64 64L64 448c-35.3 0-64-28.7-64-64z"
]
},
{
"kind": "fa",
"key": "fa:bath",
"name": "bath",
"label": "Bath",
"viewBox": "0 0 512 512",
"paths": [
"M96 77.3c0-7.3 5.9-13.3 13.3-13.3 3.5 0 6.9 1.4 9.4 3.9l14.9 14.9c-3.6 9.1-5.5 18.9-5.5 29.2 0 19.9 7.2 38 19.2 52-5.3 9.2-4 21.1 3.8 29 9.4 9.4 24.6 9.4 33.9 0L289 89c9.4-9.4 9.4-24.6 0-33.9-7.8-7.9-19.8-9.1-29-3.8-14-12-32.1-19.2-52-19.2-10.3 0-20.2 2-29.2 5.5L163.9 22.6C149.4 8.1 129.7 0 109.3 0 66.6 0 32 34.6 32 77.3L32 256c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 48c0 28.4 12.4 54 32 71.6L64 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-16 256 0 0 16c0 17.7 14.3 32 32 32s32-14.3 32-32l0-40.4c19.6-17.6 32-43.1 32-71.6l0-48c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 256 96 77.3z"
]
},
{
"kind": "fa",
"key": "fa:tooth",
"name": "tooth",
"label": "Teeth",
"viewBox": "0 0 448 512",
"paths": [
"M145 5.7L224 32 303 5.7C314.3 1.9 326 0 337.9 0 398.7 0 448 49.3 448 110.1l0 68.5c0 29.4-9.5 58.1-27.2 81.6l-1.1 1.5c-12.9 17.2-21.3 37.4-24.3 58.7L373.7 471.9c-3.3 23-23 40.1-46.2 40.1-22.8 0-42.3-16.5-46-39L261.3 351.6c-3-18.2-18.8-31.6-37.3-31.6s-34.2 13.4-37.3 31.6L166.5 473c-3.8 22.5-23.2 39-46 39-23.2 0-42.9-17.1-46.2-40.1L52.6 320.5c-3-21.3-11.4-41.5-24.3-58.7l-1.1-1.5C9.5 236.7 0 208.1 0 178.7l0-68.5C0 49.3 49.3 0 110.1 0 122 0 133.7 1.9 145 5.7z"
]
},
{
"kind": "fa",
"key": "fa:utensils",
"name": "utensils",
"label": "Meal",
"viewBox": "0 0 512 512",
"paths": [
"M63.9 14.4C63.1 6.2 56.2 0 48 0s-15.1 6.2-16 14.3L17.9 149.7c-1.3 6-1.9 12.1-1.9 18.2 0 45.9 35.1 83.6 80 87.7L96 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-224.4c44.9-4.1 80-41.8 80-87.7 0-6.1-.6-12.2-1.9-18.2L223.9 14.3C223.1 6.2 216.2 0 208 0s-15.1 6.2-15.9 14.4L178.5 149.9c-.6 5.7-5.4 10.1-11.1 10.1-5.8 0-10.6-4.4-11.2-10.2L143.9 14.6C143.2 6.3 136.3 0 128 0s-15.2 6.3-15.9 14.6L99.8 149.8c-.5 5.8-5.4 10.2-11.2 10.2-5.8 0-10.6-4.4-11.1-10.1L63.9 14.4zM448 0C432 0 320 32 320 176l0 112c0 35.3 28.7 64 64 64l32 0 0 128c0 17.7 14.3 32 32 32s32-14.3 32-32l0-448c0-17.7-14.3-32-32-32z"
]
},
{
"kind": "fa",
"key": "fa:bowl-food",
"name": "bowl-food",
"label": "Set the table",
"viewBox": "0 0 512 512",
"paths": [
"M0 176c0-35.3 28.7-64 64-64 .5 0 1.1 0 1.6 0 7.4-36.5 39.7-64 78.4-64 15 0 29 4.1 40.9 11.2 13.3-25.7 40.1-43.2 71.1-43.2s57.8 17.6 71.1 43.2c12-7.1 26-11.2 40.9-11.2 38.7 0 71 27.5 78.4 64 .5 0 1.1 0 1.6 0 35.3 0 64 28.7 64 64 0 11.7-3.1 22.6-8.6 32L8.6 208C3.1 198.6 0 187.7 0 176zM0 283.4C0 268.3 12.3 256 27.4 256l457.1 0c15.1 0 27.4 12.3 27.4 27.4 0 70.5-44.4 130.7-106.7 154.1L403.5 452c-2 16-15.6 28-31.8 28l-231.5 0c-16.1 0-29.8-12-31.8-28l-1.8-14.4C44.4 414.1 0 353.9 0 283.4z"
]
},
{
"kind": "fa",
"key": "fa:basket-shopping",
"name": "basket-shopping",
"label": "Groceries",
"viewBox": "0 0 576 512",
"paths": [
"M288 0c6.6 0 12.9 2.7 17.4 7.5l144 152 .5 .5 78.1 0c17.7 0 32 14.3 32 32 0 14.5-9.6 26.7-22.8 30.7L491.1 429.9c-6.5 29.3-32.5 50.1-62.5 50.1l-281.3 0c-30 0-56-20.8-62.5-50.1l-46-207.2c-13.2-3.9-22.8-16.2-22.8-30.7 0-17.7 14.3-32 32-32l78.1 0 .5-.5 144-152C275.1 2.7 281.4 0 288 0zm0 58.9L192.2 160 383.8 160 288 58.9zM208 264c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112zm80-24c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm128 24c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112z"
]
},
{
"kind": "fa",
"key": "fa:trash-can",
"name": "trash-can",
"label": "Bins",
"viewBox": "0 0 448 512",
"paths": [
"M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"
]
},
{
"kind": "fa",
"key": "fa:recycle",
"name": "recycle",
"label": "Recycling",
"viewBox": "0 0 512 512",
"paths": [
"M152.3 60C198.5-20 314-20 360.2 60l37.3 64.6 27.7-16c8.4-4.9 18.9-4.2 26.6 1.7s11.1 15.9 8.6 25.3L436.9 223c-3.4 12.8-16.6 20.4-29.4 17l-87.4-23.4c-9.4-2.5-16.3-10.4-17.6-20s3.4-19.1 11.8-23.9l27.7-16-37.3-64.6c-21.6-37.3-75.4-37.3-97 0l-5.3 9.1c-8.8 15.3-28.4 20.5-43.7 11.7S138.2 84.5 147 69.1l5.3-9.1zM449.7 279.1c15.3-8.8 34.9-3.6 43.7 11.7l5.3 9.1c46.2 80-11.5 180-103.9 180l-74.6 0 0 32c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-64-64c-9.4-9.4-9.4-24.6 0-33.9l64-64c6.9-6.9 17.2-8.9 26.2-5.2s14.8 12.5 14.8 22.2l0 32 74.6 0c43.1 0 70.1-46.7 48.5-84l-5.3-9.1c-8.8-15.3-3.6-34.9 11.7-43.7zM51 235.4l-27.7-16c-8.4-4.9-13.1-14.3-11.8-23.9s8.2-17.5 17.6-20L116.5 152c12.8-3.4 26 4.2 29.4 17l23.4 87.4c2.5 9.4-.9 19.3-8.6 25.3s-18.2 6.6-26.6 1.7l-27.7-16-37.3 64.6c-21.6 37.3 5.4 84 48.5 84l10.6 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-10.6 0C25.3 480-32.4 380 13.8 300L51 235.4z"
]
},
{
"kind": "fa",
"key": "fa:dog",
"name": "dog",
"label": "Dog",
"viewBox": "0 0 576 512",
"paths": [
"M32 112c16.6 0 30.2 12.6 31.8 28.7l.3 6.6C65.8 163.4 79.4 176 96 176l179.1 0 140.9 60.4 0 243.6c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-131.3C296 361 268.8 368 240 368s-56-7-80-19.3L160 480c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-245.6c-37.3-13.2-64-48.6-64-90.4 0-17.7 14.3-32 32-32zM355.8-32c7.7 0 14.9 3.6 19.6 9.8L392 0 444.1 0c12.7 0 24.9 5.1 33.9 14.1L496 32 552 32c13.3 0 24 10.7 24 24l0 24c0 44.2-35.8 80-80 80l-64 0-7 28-124.7-53.4 31.6-147.2C334.3-23.9 344.2-32 355.8-32zM448 44a20 20 0 1 0 0 40 20 20 0 1 0 0-40z"
]
},
{
"kind": "fa",
"key": "fa:cat",
"name": "cat",
"label": "Cat",
"viewBox": "0 0 576 512",
"paths": [
"M64 96c53 0 96 43 96 96l0 85.8c29.7-44.7 77.8-76.2 133.4-84 25.6 60 85.2 102.1 154.6 102.1 10.9 0 21.6-1.1 32-3.1L480 480c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-140.8-136 108.8 56 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-144 0c-53 0-96-43-96-96l0-224c0-16.6-12.6-30.2-28.7-31.8l-6.6-.3C44.6 158.2 32 144.6 32 128 32 110.3 46.3 96 64 96zM533.8 3.2C544.2-5.5 560 1.9 560 15.5L560 128c0 61.9-50.1 112-112 112S336 189.9 336 128l0-112.5c0-13.6 15.8-21 26.2-12.3L416 48 480 48 533.8 3.2zM400 108a20 20 0 1 0 0 40 20 20 0 1 0 0-40zm96 0a20 20 0 1 0 0 40 20 20 0 1 0 0-40z"
]
},
{
"kind": "fa",
"key": "fa:paw",
"name": "paw",
"label": "Pets",
"viewBox": "0 0 512 512",
"paths": [
"M234.5 92.9c14.3 42.9-.3 86.2-32.6 96.8s-70.1-15.6-84.4-58.5 .3-86.2 32.6-96.8 70.1 15.6 84.4 58.5zM100.4 198.6c18.9 32.4 14.3 70.1-10.2 84.1s-59.7-.9-78.5-33.3-14.3-70.1 10.2-84.1 59.7 .9 78.5 33.3zM69.2 401.2C121.6 259.9 214.7 224 256 224s134.4 35.9 186.8 177.2c3.6 9.7 5.2 20.1 5.2 30.5l0 1.6c0 25.8-20.9 46.7-46.7 46.7-11.5 0-22.9-1.4-34-4.2l-88-22c-15.3-3.8-31.3-3.8-46.6 0l-88 22c-11.1 2.8-22.5 4.2-34 4.2-25.8 0-46.7-20.9-46.7-46.7l0-1.6c0-10.4 1.6-20.8 5.2-30.5zM421.8 282.7c-24.5-14-29.1-51.7-10.2-84.1s54-47.3 78.5-33.3 29.1 51.7 10.2 84.1-54 47.3-78.5 33.3zM310.1 189.7c-32.3-10.6-46.9-53.9-32.6-96.8s52.1-69.1 84.4-58.5 46.9 53.9 32.6 96.8-52.1 69.1-84.4 58.5z"
]
},
{
"kind": "fa",
"key": "fa:seedling",
"name": "seedling",
"label": "Plants",
"viewBox": "0 0 512 512",
"paths": [
"M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"
]
},
{
"kind": "fa",
"key": "fa:book",
"name": "book",
"label": "Reading",
"viewBox": "0 0 448 512",
"paths": [
"M384 512L96 512c-53 0-96-43-96-96L0 96C0 43 43 0 96 0L400 0c26.5 0 48 21.5 48 48l0 288c0 20.9-13.4 38.7-32 45.3l0 66.7c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zM96 384c-17.7 0-32 14.3-32 32s14.3 32 32 32l256 0 0-64-256 0zm32-232c0 13.3 10.7 24 24 24l176 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-176 0c-13.3 0-24 10.7-24 24zm24 72c-13.3 0-24 10.7-24 24s10.7 24 24 24l176 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-176 0z"
]
}
];
export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
/** Mirrors core/icons.py EMOJI_MAX_CODE_POINTS. */
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]));
function isEmojiPayload(text: string): boolean {
const codePoints = [...text];
if (codePoints.length === 0 || codePoints.length > EMOJI_MAX_CODE_POINTS) return false;
return !codePoints.some((char) => /[\s<>&]|\p{Cc}/u.test(char));
}
/**
* 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
* `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.
*/
export function resolveIcon(value: string | null | undefined): ResolvedIcon {
if (!value || !value.trim()) return FALLBACK_ICON;
const colon = value.indexOf(":");
if (colon === -1) return LUCIDE_BY_NAME.get(value) ?? FALLBACK_ICON;
const namespace = value.slice(0, colon);
const name = value.slice(colon + 1);
if (namespace === "lucide") return LUCIDE_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "fa") return FA_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "emoji" && isEmojiPayload(name)) return { kind: "emoji", key: value, text: name };
return FALLBACK_ICON;
}
+146
View File
@@ -0,0 +1,146 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
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";
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
type EmojiPayloadCase = { payload: string; valid: boolean; why: string };
const emojiPayloadCases = JSON.parse(
readFileSync(join(REPO_ROOT, "tools", "icons", "emoji-payload-cases.json"), "utf8"),
) as { cases: EmojiPayloadCase[] };
describe("shared icon registry — one source of truth", () => {
it("both committed artefacts are byte-identical to a fresh generation", async () => {
const fresh = await renderRegistry();
expect(OUTPUT_PATHS).toHaveLength(2);
for (const path of OUTPUT_PATHS) {
expect(readFileSync(path, "utf8"), `${path} drifted — run node tools/icons/generate.mjs`).toBe(fresh);
}
});
it("the generated sets carry exactly the curated names and labels, in order", () => {
expect(LUCIDE_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.lucide);
expect(FA_ICONS.map(({ name, label }) => ({ name, label }))).toEqual(curated.fa);
expect(curated.lucide).toHaveLength(36);
expect(FA_ICONS.length).toBeGreaterThanOrEqual(15);
expect(FA_ICONS.length).toBeLessThanOrEqual(25);
});
it("every glyph is plain data: a viewBox and at least one path", () => {
for (const icon of LUCIDE_ICONS) {
expect(icon.kind).toBe("lucide");
expect(icon.key).toBe(`lucide:${icon.name}`);
expect(icon.viewBox).toBe("0 0 24 24");
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of FA_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]) {
for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
}
});
});
describe("resolveIcon", () => {
it("resolves lucide:, fa: and emoji: keys", () => {
const dog = resolveIcon("lucide:dog");
expect(dog.kind).toBe("lucide");
expect(dog).toBe(LUCIDE_ICONS.find((icon) => icon.name === "dog"));
const broom = resolveIcon("fa:broom");
expect(broom.kind).toBe("fa");
expect(broom).toBe(FA_ICONS.find((icon) => icon.name === "broom"));
expect(resolveIcon("emoji:🐶")).toEqual({ kind: "emoji", key: "emoji:🐶", text: "🐶" });
expect(resolveIcon("emoji:👨‍👩‍👧")).toEqual({ kind: "emoji", key: "emoji:👨‍👩‍👧", text: "👨‍👩‍👧" });
});
it("reads a legacy bare name as Lucide", () => {
expect(resolveIcon("dog")).toBe(resolveIcon("lucide:dog"));
expect(resolveIcon("trash-2")).toBe(LUCIDE_ICONS.find((icon) => icon.name === "trash-2"));
});
it("falls back — never throws — for empty or unknown values", () => {
for (const value of [
null,
undefined,
"",
" ",
"rocket-ship-9000",
"lucide:",
"lucide:rocket-ship-9000",
"fa:",
"fa:rocket-ship-9000",
"fa:Dog",
"mystery:dog",
"emoji:",
"emoji:<b>",
"emoji:a b",
"emoji:123456789",
]) {
expect(resolveIcon(value), String(value)).toBe(FALLBACK_ICON);
}
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
});
// tests/test_core_icons.py asserts the same table against the backend's
// validate_icon, so the two emoji rules cannot drift apart silently.
it.each(emojiPayloadCases.cases)("emoji verdict matches the shared table: $why", ({ payload, valid }) => {
const value = `emoji:${payload}`;
expect(resolveIcon(value)).toEqual(valid ? { kind: "emoji", key: value, text: payload } : FALLBACK_ICON);
});
it("the shared emoji table covers the contract edges", () => {
const verdicts = new Map(emojiPayloadCases.cases.map(({ payload, valid }) => [payload, valid]));
expect(verdicts.get("\ufeff")).toBe(false);
expect(verdicts.get("👨\u200d👩\u200d👧\u200d👦")).toBe(true);
expect(verdicts.get("🐕 ")).toBe(false);
expect(verdicts.get("🐕\u0000")).toBe(false);
for (const markup of ["<b>", "a>b", "a&b"]) expect(verdicts.get(markup)).toBe(false);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS))).toBe(true);
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS + 1))).toBe(false);
});
});
function filesUnder(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? filesUnder(path) : [path];
});
}
describe("offline bundling", () => {
const registryFiles = [SOURCE_PATH, join(REPO_ROOT, "tools", "icons", "generate.mjs"), ...OUTPUT_PATHS];
it("the registry has no remote URL, CDN reference or data-lucide runtime", () => {
for (const path of registryFiles) {
const text = readFileSync(path, "utf8");
expect(text, path).not.toMatch(/https?:\/\//i);
expect(text, path).not.toMatch(/\/\/[a-z0-9.-]+\.[a-z]{2,}\//i);
expect(text, path).not.toMatch(/\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome/i);
expect(text, path).not.toContain("data-lucide");
expect(text, path).not.toMatch(/\bfetch\s*\(/);
}
});
it("no bundled source imports an icon package — only the curated generated 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"));
expect(sources.length).toBeGreaterThan(0);
for (const path of sources) {
const text = readFileSync(path, "utf8");
expect(text, path).not.toMatch(/from\s+["'](lucide|lucide-react|lucide-static|@fortawesome\/[^"']+)["']/);
}
});
});
+5
View File
@@ -152,6 +152,10 @@ core_quest_definitions = importlib.import_module(
f"{_CORE_MODULE_NAME}.quest_definitions"
)
#: The icon-key module (``core.icons.normalize_icon_for_read``) the
#: admin plane's quest-definition responses normalize ``icon`` through.
core_icons = importlib.import_module(f"{_CORE_MODULE_NAME}.icons")
#: The recurrence model (``core.recurrence.ScheduleRule`` and
#: ``RuleValidationError``) the quest-definition routes build request
#: rules with — the model validates at construction, the routes never
@@ -215,6 +219,7 @@ __all__ = [
"core_db",
"core_events",
"core_history",
"core_icons",
"core_materialize",
"core_migrations",
"core_presence",
+2 -1
View File
@@ -336,6 +336,7 @@ from api.nestquest_core import (
core_dao_children,
core_dao_rules,
core_history,
core_icons,
core_materialize,
core_presence_management,
core_quest_definitions,
@@ -1205,7 +1206,7 @@ def _quest_definition_response(
id=bundle.definition.id,
title=bundle.definition.title,
description=bundle.definition.description,
icon=bundle.definition.icon,
icon=core_icons.normalize_icon_for_read(bundle.definition.icon),
is_active=bundle.definition.is_active,
skip_on_away=bundle.definition.skip_on_away,
rule=_rule_response(bundle.rule),
+119
View File
@@ -0,0 +1,119 @@
"""Namespaced quest-definition icon keys.
A definition's ``icon`` is stored as ``<namespace>:<name>`` where the
namespace is one of :data:`ICON_NAMESPACES`:
- ``lucide:<name>`` / ``fa:<name>`` — ``<name>`` is a lowercase kebab
name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``).
- ``emoji:<payload>`` — a short emoji string of at most
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace (JS
``\\s``, so including U+FEFF), no control characters and none of
``<``, ``>``, ``&``.
Rows written before namespacing hold a bare Lucide name (``dog``).
:func:`validate_icon` (writes) normalizes such a legacy value to
``lucide:<name>``; :func:`normalize_icon_for_read` (reads) does the same
leniently so legacy rows render without a data migration.
Nothing here imports :mod:`homeassistant`.
"""
from __future__ import annotations
import re
import unicodedata
ICON_NAMESPACE_LUCIDE = "lucide"
ICON_NAMESPACE_FA = "fa"
ICON_NAMESPACE_EMOJI = "emoji"
ICON_NAMESPACES = frozenset(
{ICON_NAMESPACE_LUCIDE, ICON_NAMESPACE_FA, ICON_NAMESPACE_EMOJI}
)
#: Upper bound on an emoji payload's length in code points — enough for
#: ZWJ sequences such as a family emoji, too short for arbitrary text.
EMOJI_MAX_CODE_POINTS = 8
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
# U+FEFF (BOM / zero-width no-break space) is neither str.isspace() nor
# ``Cc`` in Python, but JS ``\s`` matches it, so it is listed here to keep
# this rule identical to the frontends' isEmojiPayload (/[\s<>&]|\p{Cc}/u).
# Other format characters, notably the ZWJ U+200D that emoji sequences
# need, stay valid in both runtimes.
_EMOJI_FORBIDDEN = frozenset("<>&\ufeff")
def _is_kebab_name(value: str) -> bool:
return _KEBAB_NAME.fullmatch(value) is not None
def _is_valid_emoji_payload(value: str) -> bool:
if not value or len(value) > EMOJI_MAX_CODE_POINTS:
return False
return not any(
char.isspace()
or char in _EMOJI_FORBIDDEN
or unicodedata.category(char) == "Cc"
for char in value
)
def validate_icon(value: object) -> str | None:
"""Return the canonical namespaced icon key, or raise naming ``icon``.
``None`` or an empty/whitespace-only string returns ``None`` (the
icon is cleared). Otherwise the raw value is validated untrimmed, so
edge whitespace or control characters are rejected rather than
silently stripped. A value with no ``:`` is a legacy bare Lucide
name and is stored as ``lucide:<value>``. An unknown namespace, an
empty or malformed name, or an invalid emoji payload raises
ValueError.
"""
if value is None:
return None
if not isinstance(value, str):
raise ValueError(f"icon must be a string, got {value!r}")
if not value.strip():
return None
namespace, separator, name = value.partition(":")
if not separator:
if not _is_kebab_name(value):
raise ValueError(
f"icon must be a lowercase kebab name, got {value!r}"
)
return f"{ICON_NAMESPACE_LUCIDE}:{value}"
if namespace not in ICON_NAMESPACES:
raise ValueError(
f"icon has an unknown namespace {namespace!r}; expected one of "
f"{sorted(ICON_NAMESPACES)}"
)
if namespace == ICON_NAMESPACE_EMOJI:
if not _is_valid_emoji_payload(name):
raise ValueError(
f"icon emoji must be 1-{EMOJI_MAX_CODE_POINTS} code points "
f"with no whitespace, control characters or markup, "
f"got {value!r}"
)
elif not _is_kebab_name(name):
raise ValueError(
f"icon {namespace} name must be a lowercase kebab name, "
f"got {value!r}"
)
return value
def normalize_icon_for_read(value: str | None) -> str | None:
"""Return a stored icon in namespaced form for output.
Lenient: ``None``/empty returns ``None``, a recognised namespace is
returned unchanged, and anything else becomes ``lucide:<value>`` so
legacy rows render (an unknown name falls through to the frontend's
fallback icon rather than erroring).
"""
if not value or not value.strip():
return None
namespace, separator, _ = value.partition(":")
if separator and namespace in ICON_NAMESPACES:
return value
return f"{ICON_NAMESPACE_LUCIDE}:{value}"
@@ -59,6 +59,7 @@ from .dao_rules import (
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .icons import validate_icon
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@@ -248,7 +249,7 @@ async def create_quest_definition(
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
icon_value = validate_icon(icon)
skip_on_away_value = _validate_skip_on_away(skip_on_away)
storage = schedule_rule_to_storage(rule)
@@ -351,7 +352,7 @@ async def edit_quest_definition(
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
icon_value = validate_icon(icon)
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
+6 -1
View File
@@ -42,6 +42,7 @@ from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresencePatternsDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .icons import normalize_icon_for_read
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
@@ -262,7 +263,11 @@ async def build_snapshot(
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
icon=(
normalize_icon_for_read(definition.icon)
if definition
else None
),
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
File diff suppressed because it is too large Load Diff
+27 -2
View File
@@ -84,8 +84,33 @@ Footer: `Cancel` (flex 1, bordered) + `Save changes` (flex 2, gradient), both 13
padding. Sheet enters over the dimmed list in `--nq-dur-modal`.
### 3.3 Icon picker
Each definition carries a Lucide glyph, shown on both surfaces. **The allowed subset is not
yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid picker.
Each definition carries one optional icon, shown on both surfaces. The picker offers three
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.
- **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 stored value is a **namespaced key**:
| Source | Stored key | Example |
|---|---|---|
| Lucide | `lucide:<name>` | `lucide:dog` |
| Font Awesome | `fa:<name>` | `fa:paw` |
| Emoji | `emoji:<grapheme>` | `emoji:🐶` |
`<name>` is a lowercase kebab name (`[a-z0-9-]`, no leading or trailing `-`). A **legacy
bare name** written before namespacing (`dog`) reads as `lucide:<name>` — the API returns it
namespaced, and any write of a bare name is stored as `lucide:<name>`; no data migration. An
empty value clears the icon. An empty, unknown, or unresolvable key renders the **fallback
glyph** — in the admin, the generic task glyph (clipboard with a check); on the panel, the
star (`PANEL-SPEC.md` §3.2).
## 4. Schedule (1f)
+3 -2
View File
@@ -78,5 +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 a Lucide subset; the subset is
not yet defined (`ADMIN-SPEC.md` §3.3).
- 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:`) —
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
+1 -1
View File
@@ -35,7 +35,7 @@ implementer's call), shaped:
{
"instances": [
{ "id": 4821, "definition_id": 12, "child_id": 3, "title": "Fill bird feeder",
"icon": "bird", "window": "afternoon", "due_time": "17:00",
"icon": "lucide:bird", "window": "afternoon", "due_time": "17:00",
"state": "open", "overdue": true, "completed_at": null, "on_time": null }
]
}
+11 -2
View File
@@ -91,7 +91,14 @@ collapse the column, the three-column rhythm is the layout.
`--nq-p-quest-min-height` 116px, radius 12px, `--nq-p-card-open`, 1px
`--nq-p-card-border`, `--nq-p-card-shadow`, padding 22px, 22px gap, row layout:
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, Lucide glyph 34px white.
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**;
`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
shared registry — nothing is fetched at runtime.
2. **Text** title 30px/700 Cinzel `--nq-p-ink`; meta 22px/600 Nunito
`--nq-p-ink-secondary` reading `Due by <h:mm A>`. If past due:
`Overdue · due <h:mm A>` in 22px/700 Cinzel uppercase, tracking .10em,
@@ -106,7 +113,9 @@ Both the card body and the button open the confirm dialog (§4). Press feedback:
### 3.3 Quest card — sealed
`--nq-p-card-done`, `opacity: .82`, no shadow, **not tappable**. Icon tile
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. Title 30px/700 Cinzel
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. The sealed tile keeps
the static star glyph — it does **not** render the definition icon — alongside the wax
seal below. Title 30px/700 Cinzel
`--nq-p-ink-muted` with `text-decoration: line-through`. Meta reads
`Claimed <h:mm A>` 22px/600 Nunito `--nq-p-ink-secondary`.
+7 -1
View File
@@ -2,6 +2,7 @@ import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "
import { repeat } from "lit/directives/repeat.js";
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
import { renderQuestIcon } from "../icons/render";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
type QuestLogCardConfig = CardConfig & {
@@ -614,6 +615,11 @@ const logStyles = css`
height: 34px;
}
.tile .emoji {
font-size: 30px;
line-height: 1;
}
.sealed .tile {
background: var(--nq-p-icon-tile-done);
color: var(--nq-p-ink-secondary);
@@ -2093,7 +2099,7 @@ export class NestQuestQuestLogCard extends LitElement {
}
}}
>
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
<span class="tile" aria-hidden="true">${renderQuestIcon(instance.icon, ICON_STAR)}</span>
<div class="body">
<span class="quest-title">${instance.title}</span>
<span class="meta ${meta.late ? "late" : nothing}">${meta.text}</span>
+765
View File
@@ -0,0 +1,765 @@
// GENERATED by tools/icons/generate.mjs from tools/icons/curated-icons.json — do not edit.
// Regenerate with `node tools/icons/generate.mjs` (the drift tests fail if this
// file differs from a fresh generation).
//
// Glyph path data is embedded at generation time; nothing is fetched at runtime.
// Lucide glyphs: lucide-react 1.47.0 (ISC licence).
// Font Awesome glyphs: @fortawesome/free-solid-svg-icons 7.3.1
// (Font Awesome Free, icons under CC BY 4.0).
/** A vector glyph: draw each path in `paths` inside an <svg viewBox>. Lucide
* glyphs are stroked (fill none, stroke-width 2, round caps and joins);
* Font Awesome glyphs are filled. */
export interface GlyphIcon {
kind: "lucide" | "fa";
key: string;
name: string;
label: string;
viewBox: string;
paths: readonly string[];
}
/** An emoji icon; render `text` as text. */
export interface EmojiIcon {
kind: "emoji";
key: string;
text: string;
}
/** No usable icon: the caller draws its own default glyph. */
export interface FallbackIcon {
kind: "fallback";
key: null;
}
export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
export const LUCIDE_ICONS: readonly GlyphIcon[] = [
{
"kind": "lucide",
"key": "lucide:bed",
"name": "bed",
"label": "Bed",
"viewBox": "0 0 24 24",
"paths": [
"M2 4v16",
"M2 8h18a2 2 0 0 1 2 2v10",
"M2 17h20",
"M6 8v9"
]
},
{
"kind": "lucide",
"key": "lucide:bath",
"name": "bath",
"label": "Bath",
"viewBox": "0 0 24 24",
"paths": [
"M10 4 8 6",
"M17 19v2",
"M2 12h20",
"M7 19v2",
"M9 5 7.621 3.621A2.121 2.121 0 0 0 4 5v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"
]
},
{
"kind": "lucide",
"key": "lucide:shower-head",
"name": "shower-head",
"label": "Shower",
"viewBox": "0 0 24 24",
"paths": [
"m4 4 2.5 2.5",
"M13.5 6.5a4.95 4.95 0 0 0-7 7",
"M15 5 5 15",
"M14 17v.01",
"M10 16v.01",
"M13 13v.01",
"M16 10v.01",
"M11 20v.01",
"M17 14v.01",
"M20 11v.01"
]
},
{
"kind": "lucide",
"key": "lucide:toilet",
"name": "toilet",
"label": "Toilet",
"viewBox": "0 0 24 24",
"paths": [
"M7 12h13a1 1 0 0 1 1 1 5 5 0 0 1-5 5h-.598a.5.5 0 0 0-.424.765l1.544 2.47a.5.5 0 0 1-.424.765H5.402a.5.5 0 0 1-.424-.765L7 18",
"M8 18a5 5 0 0 1-5-5V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8"
]
},
{
"kind": "lucide",
"key": "lucide:brush",
"name": "brush",
"label": "Brush",
"viewBox": "0 0 24 24",
"paths": [
"m11 10 3 3",
"M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z",
"M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
]
},
{
"kind": "lucide",
"key": "lucide:smile",
"name": "smile",
"label": "Smile",
"viewBox": "0 0 24 24",
"paths": [
"M15 10V9",
"M16.472 15a6 6 0 01-8.943 0",
"M9 10V9",
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0"
]
},
{
"kind": "lucide",
"key": "lucide:shirt",
"name": "shirt",
"label": "Clothes",
"viewBox": "0 0 24 24",
"paths": [
"M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"
]
},
{
"kind": "lucide",
"key": "lucide:washing-machine",
"name": "washing-machine",
"label": "Laundry",
"viewBox": "0 0 24 24",
"paths": [
"M3 6h3",
"M17 6h.01",
"M5 2h14a2 2 0 0 1 2 2v16a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-16a2 2 0 0 1 2 -2z",
"M7 13a5 5 0 1 0 10 0a5 5 0 1 0 -10 0",
"M12 18a2.5 2.5 0 0 0 0-5 2.5 2.5 0 0 1 0-5"
]
},
{
"kind": "lucide",
"key": "lucide:utensils",
"name": "utensils",
"label": "Meal",
"viewBox": "0 0 24 24",
"paths": [
"M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2",
"M7 2v20",
"M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"
]
},
{
"kind": "lucide",
"key": "lucide:cooking-pot",
"name": "cooking-pot",
"label": "Cooking",
"viewBox": "0 0 24 24",
"paths": [
"M2 12h20",
"M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8",
"m4 8 16-4",
"m8.86 6.78-.45-1.81a2 2 0 0 1 1.45-2.43l1.94-.48a2 2 0 0 1 2.43 1.46l.45 1.8"
]
},
{
"kind": "lucide",
"key": "lucide:apple",
"name": "apple",
"label": "Snack",
"viewBox": "0 0 24 24",
"paths": [
"M12 6.528V3a1 1 0 0 1 1-1h0",
"M18.237 21A15 15 0 0 0 22 11a6 6 0 0 0-10-4.472A6 6 0 0 0 2 11a15.1 15.1 0 0 0 3.763 10 3 3 0 0 0 3.648.648 5.5 5.5 0 0 1 5.178 0A3 3 0 0 0 18.237 21"
]
},
{
"kind": "lucide",
"key": "lucide:brush-cleaning",
"name": "brush-cleaning",
"label": "Sweep",
"viewBox": "0 0 24 24",
"paths": [
"m16 22-1-4",
"M19 14a1 1 0 0 0 1-1v-1a2 2 0 0 0-2-2h-3a1 1 0 0 1-1-1V4a2 2 0 0 0-4 0v5a1 1 0 0 1-1 1H6a2 2 0 0 0-2 2v1a1 1 0 0 0 1 1",
"M19 14H5l-1.973 6.767A1 1 0 0 0 4 22h16a1 1 0 0 0 .973-1.233z",
"m8 22 1-4"
]
},
{
"kind": "lucide",
"key": "lucide:spray-can",
"name": "spray-can",
"label": "Clean",
"viewBox": "0 0 24 24",
"paths": [
"M3 3h.01",
"M7 5h.01",
"M11 7h.01",
"M3 7h.01",
"M7 9h.01",
"M3 11h.01",
"M15 5h4v4h-4z",
"m19 9 2 2v10c0 .6-.4 1-1 1h-6c-.6 0-1-.4-1-1V11l2-2",
"m13 14 8-2",
"m13 19 8-2"
]
},
{
"kind": "lucide",
"key": "lucide:trash-2",
"name": "trash-2",
"label": "Bins",
"viewBox": "0 0 24 24",
"paths": [
"M10 11v6",
"M14 11v6",
"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",
"M3 6h18",
"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
]
},
{
"kind": "lucide",
"key": "lucide:recycle",
"name": "recycle",
"label": "Recycling",
"viewBox": "0 0 24 24",
"paths": [
"M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5",
"M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12",
"m14 16-3 3 3 3",
"M8.293 13.596 7.196 9.5 3.1 10.598",
"m9.344 5.811 1.093-1.892A1.83 1.83 0 0 1 11.985 3a1.784 1.784 0 0 1 1.546.888l3.943 6.843",
"m13.378 9.633 4.096 1.098 1.097-4.096"
]
},
{
"kind": "lucide",
"key": "lucide:sofa",
"name": "sofa",
"label": "Tidy",
"viewBox": "0 0 24 24",
"paths": [
"M20 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3",
"M2 16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z",
"M4 18v2",
"M20 18v2",
"M12 4v9"
]
},
{
"kind": "lucide",
"key": "lucide:house",
"name": "house",
"label": "Home",
"viewBox": "0 0 24 24",
"paths": [
"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8",
"M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
]
},
{
"kind": "lucide",
"key": "lucide:backpack",
"name": "backpack",
"label": "School bag",
"viewBox": "0 0 24 24",
"paths": [
"M4 10a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z",
"M8 10h8",
"M8 18h8",
"M8 22v-6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v6",
"M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"
]
},
{
"kind": "lucide",
"key": "lucide:book-open",
"name": "book-open",
"label": "Reading",
"viewBox": "0 0 24 24",
"paths": [
"M12 5v16",
"M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z"
]
},
{
"kind": "lucide",
"key": "lucide:pencil",
"name": "pencil",
"label": "Homework",
"viewBox": "0 0 24 24",
"paths": [
"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",
"m15 5 4 4"
]
},
{
"kind": "lucide",
"key": "lucide:music",
"name": "music",
"label": "Music",
"viewBox": "0 0 24 24",
"paths": [
"M9 18V5l12-2v13",
"M3 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0",
"M15 16a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"
]
},
{
"kind": "lucide",
"key": "lucide:dog",
"name": "dog",
"label": "Dog",
"viewBox": "0 0 24 24",
"paths": [
"M11.25 16.25h1.5L12 17z",
"M16 14v.5",
"M4.42 11.247A13.152 13.152 0 0 0 4 14.556C4 18.728 7.582 21 12 21s8-2.272 8-6.444a11.702 11.702 0 0 0-.493-3.309",
"M8 14v.5",
"M8.5 8.5c-.384 1.05-1.083 2.028-2.344 2.5-1.931.722-3.576-.297-3.656-1-.113-.994 1.177-6.53 4-7 1.923-.321 3.651.845 3.651 2.235A7.497 7.497 0 0 1 14 5.277c0-1.39 1.844-2.598 3.767-2.277 2.823.47 4.113 6.006 4 7-.08.703-1.725 1.722-3.656 1-1.261-.472-1.855-1.45-2.239-2.5"
]
},
{
"kind": "lucide",
"key": "lucide:cat",
"name": "cat",
"label": "Cat",
"viewBox": "0 0 24 24",
"paths": [
"M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5Z",
"M8 14v.5",
"M16 14v.5",
"M11.25 16.25h1.5L12 17l-.75-.75Z"
]
},
{
"kind": "lucide",
"key": "lucide:fish",
"name": "fish",
"label": "Fish",
"viewBox": "0 0 24 24",
"paths": [
"M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6Z",
"M18 12v.5",
"M16 17.93a9.77 9.77 0 0 1 0-11.86",
"M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 1.5-1 5 .23 6.5-1.24 1.5-1.24 5-.23 6.5C5.58 18.03 7 16 7 13.33",
"M10.46 7.26C10.2 5.88 9.17 4.24 8 3h5.8a2 2 0 0 1 1.98 1.67l.23 1.4",
"m16.01 17.93-.23 1.4A2 2 0 0 1 13.8 21H9.5a5.96 5.96 0 0 0 1.49-3.98"
]
},
{
"kind": "lucide",
"key": "lucide:sprout",
"name": "sprout",
"label": "Plants",
"viewBox": "0 0 24 24",
"paths": [
"M14 9.536V7a4 4 0 0 1 4-4h1.5a.5.5 0 0 1 .5.5V5a4 4 0 0 1-4 4 4 4 0 0 0-4 4c0 2 1 3 1 5a5 5 0 0 1-1 3",
"M4 9a5 5 0 0 1 8 4 5 5 0 0 1-8-4",
"M5 21h14"
]
},
{
"kind": "lucide",
"key": "lucide:droplets",
"name": "droplets",
"label": "Water",
"viewBox": "0 0 24 24",
"paths": [
"M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z",
"M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"
]
},
{
"kind": "lucide",
"key": "lucide:bike",
"name": "bike",
"label": "Bike",
"viewBox": "0 0 24 24",
"paths": [
"M15 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0",
"M2 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0",
"M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0",
"M12 17.5V14l-3-3 4-3 2 3h2"
]
},
{
"kind": "lucide",
"key": "lucide:volleyball",
"name": "volleyball",
"label": "Sport",
"viewBox": "0 0 24 24",
"paths": [
"M11 7a16 16 20 0 1 10.98 4.362",
"M12 12a13 13 0 0 1-8.66 5",
"M16.83 13.634a16 16 0 0 1-9.267 7.328",
"M20.66 17A13 13 0 0 0 12 12a13 13 0 0 1 0-10",
"M8.17 15.366a16 16 0 0 1-1.713-11.69",
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0"
]
},
{
"kind": "lucide",
"key": "lucide:gamepad-2",
"name": "gamepad-2",
"label": "Games",
"viewBox": "0 0 24 24",
"paths": [
"M6 11L10 11",
"M8 9L8 13",
"M15 12L15.01 12",
"M18 10L18.01 10",
"M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"
]
},
{
"kind": "lucide",
"key": "lucide:star",
"name": "star",
"label": "Star",
"viewBox": "0 0 24 24",
"paths": [
"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
]
},
{
"kind": "lucide",
"key": "lucide:heart",
"name": "heart",
"label": "Heart",
"viewBox": "0 0 24 24",
"paths": [
"M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"
]
},
{
"kind": "lucide",
"key": "lucide:sun",
"name": "sun",
"label": "Morning",
"viewBox": "0 0 24 24",
"paths": [
"M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0",
"M12 2v2",
"M12 20v2",
"m4.93 4.93 1.41 1.41",
"m17.66 17.66 1.41 1.41",
"M2 12h2",
"M20 12h2",
"m6.34 17.66-1.41 1.41",
"m19.07 4.93-1.41 1.41"
]
},
{
"kind": "lucide",
"key": "lucide:moon",
"name": "moon",
"label": "Bedtime",
"viewBox": "0 0 24 24",
"paths": [
"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
]
},
{
"kind": "lucide",
"key": "lucide:clock",
"name": "clock",
"label": "Clock",
"viewBox": "0 0 24 24",
"paths": [
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0",
"M12 6v6l4 2"
]
},
{
"kind": "lucide",
"key": "lucide:calendar",
"name": "calendar",
"label": "Calendar",
"viewBox": "0 0 24 24",
"paths": [
"M8 2v3",
"M16 2v3",
"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2z",
"M3 9h18"
]
},
{
"kind": "lucide",
"key": "lucide:list-checks",
"name": "list-checks",
"label": "Checklist",
"viewBox": "0 0 24 24",
"paths": [
"M13 5h8",
"M13 12h8",
"M13 19h8",
"m3 17 2 2 4-4",
"m3 7 2 2 4-4"
]
}
];
export const FA_ICONS: readonly GlyphIcon[] = [
{
"kind": "fa",
"key": "fa:broom",
"name": "broom",
"label": "Sweep",
"viewBox": "0 0 576 512",
"paths": [
"M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6-12.5 0-22.6 10.1-22.6 22.6l0 29.1 108.3 108.3 29.1 0c12.5 0 22.6-10.1 22.6-22.6 0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8c-22.3 22.3-34.8 52.5-34.8 84 0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9 0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8 30.6-30.6 45.9-73.1 42.3-115.8z"
]
},
{
"kind": "fa",
"key": "fa:spray-can-sparkles",
"name": "spray-can-sparkles",
"label": "Clean",
"viewBox": "0 0 576 512",
"paths": [
"M160 32l0 80 128 0 0-80c0-17.7-14.3-32-32-32L192 0c-17.7 0-32 14.3-32 32zm0 128c-53 0-96 43-96 96l0 208c0 26.5 21.5 48 48 48l224 0c26.5 0 48-21.5 48-48l0-208c0-53-43-96-96-96l-128 0zm64 96a80 80 0 1 1 0 160 80 80 0 1 1 0-160zM448 48c0-1.4-1-3-2.2-3.6L416 32 403.6 2.2C403 1 401.4 0 400 0s-3 1-3.6 2.2L384 32 354.2 44.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L384 64 396.4 93.8C397 95 398.6 96 400 96s3-1 3.6-2.2L416 64 445.8 51.6C447 51 448 49.4 448 48zm76.4 45.8C525 95 526.6 96 528 96s3-1 3.6-2.2L544 64 573.8 51.6c1.2-.6 2.2-2.2 2.2-3.6 0-1.4-1-3-2.2-3.6L544 32 531.6 2.2C531 1 529.4 0 528 0s-3 1-3.6 2.2L512 32 482.2 44.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L512 64 524.4 93.8zm7.2 100.4c-.6-1.2-2.2-2.2-3.6-2.2s-3 1-3.6 2.2L512 224 482.2 236.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L512 256 524.4 285.8c.6 1.2 2.2 2.2 3.6 2.2s3-1 3.6-2.2L544 256 573.8 243.6c1.2-.6 2.2-2.2 2.2-3.6 0-1.4-1-3-2.2-3.6L544 224 531.6 194.2zM512 144c0-1.4-1-3-2.2-3.6L480 128 467.6 98.2C467 97 465.4 96 464 96s-3 1-3.6 2.2L448 128 418.2 140.4c-1.2 .6-2.2 2.2-2.2 3.6 0 1.4 1 3 2.2 3.6L448 160 460.4 189.8c.6 1.2 2.2 2.2 3.6 2.2s3-1 3.6-2.2L480 160 509.8 147.6c1.2-.6 2.2-2.2 2.2-3.6z"
]
},
{
"kind": "fa",
"key": "fa:bucket",
"name": "bucket",
"label": "Mop",
"viewBox": "0 0 448 512",
"paths": [
"M443.7 208c2.7 4.7 4.3 10.2 4.3 16 0 17.7-14.3 32-32 32l-5.1 0-22.4 213c-2.6 24.4-23.2 43-47.8 43l-233.6 0c-24.6 0-45.2-18.5-47.8-43L37.1 256 32 256c-17.7 0-32-14.3-32-32 0-5.8 1.6-11.3 4.3-16l439.4 0zM224-16c79.5 0 144 64.5 144 144l0 32-48 0 0-32c0-53-43-96-96-96s-96 43-96 96l0 32-48 0 0-32C80 48.5 144.5-16 224-16z"
]
},
{
"kind": "fa",
"key": "fa:soap",
"name": "soap",
"label": "Wash hands",
"viewBox": "0 0 512 512",
"paths": [
"M208-32a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM320 96a64 64 0 1 1 0 128 64 64 0 1 1 0-128zM352 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM96 160l112 0c0 23.8 7.4 45.9 20.1 64L160 224c-53 0-96 43-96 96s43 96 96 96l192 0c53 0 96-43 96-96 0-35.1-18.9-65.9-47-82.6 19-19.8 30.7-46.6 31-76.1 45.4 7.6 80 47.1 80 94.7l0 128c0 53-43 96-96 96L96 480c-53 0-96-43-96-96L0 256c0-53 43-96 96-96zm64 112l192 0c26.5 0 48 21.5 48 48s-21.5 48-48 48l-192 0c-26.5 0-48-21.5-48-48s21.5-48 48-48z"
]
},
{
"kind": "fa",
"key": "fa:sink",
"name": "sink",
"label": "Dishes",
"viewBox": "0 0 512 512",
"paths": [
"M288 96c0-17.7 14.3-32 32-32s32 14.3 32 32 14.3 32 32 32 32-14.3 32-32c0-53-43-96-96-96s-96 43-96 96l0 192-64 0 0-40c0-30.9-25.1-56-56-56l-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l48 0c4.4 0 8 3.6 8 8l0 40-80 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 64c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-64c17.7 0 32-14.3 32-32s-14.3-32-32-32l-80 0 0-40c0-4.4 3.6-8 8-8l56 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-56 0c-30.9 0-56 25.1-56 56l0 40-64 0 0-192z"
]
},
{
"kind": "fa",
"key": "fa:jug-detergent",
"name": "jug-detergent",
"label": "Laundry",
"viewBox": "0 0 384 512",
"paths": [
"M96 24c0-13.3 10.7-24 24-24l80 0c13.3 0 24 10.7 24 24l0 24 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L88 96C74.7 96 64 85.3 64 72S74.7 48 88 48l8 0 0-24zM0 256c0-70.7 57.3-128 128-128l128 0c70.7 0 128 57.3 128 128l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256zm256 0l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32z"
]
},
{
"kind": "fa",
"key": "fa:socks",
"name": "socks",
"label": "Socks",
"viewBox": "0 0 512 512",
"paths": [
"M252.8 0L176 0c-26.5 0-48 21.5-48 48l0 16 112 0 0-16c0-17.5 4.7-33.9 12.8-48zM128 112l0 128c0 20.1-9.5 39.1-25.6 51.2l-64 48c-24.2 18.1-38.4 46.6-38.4 76.8 0 53 43 96 96 96 15.4 0 30.5-3.7 44-10.7-17.6-23.9-28-53.4-28-85.3 0-45.3 21.3-88 57.6-115.2l64-48c4-3 6.4-7.8 6.4-12.8l0-128-112 0zm160 0l0 128c0 20.1-9.5 39.1-25.6 51.2l-64 48c-24.2 18.1-38.4 46.6-38.4 76.8 0 53 43 96 96 96 20.8 0 41-6.7 57.6-19.2l115.2-86.4C461 382.2 480 344.3 480 304l0-192-192 0zM480 64l0-16c0-26.5-21.5-48-48-48L336 0c-26.5 0-48 21.5-48 48l0 16 192 0z"
]
},
{
"kind": "fa",
"key": "fa:shirt",
"name": "shirt",
"label": "Clothes",
"viewBox": "0 0 640 512",
"paths": [
"M320.2 112c44.2 0 80-35.8 80-80l53.5 0c17 0 33.3 6.7 45.3 18.7L617.6 169.4c12.5 12.5 12.5 32.8 0 45.3l-50.7 50.7c-12.5 12.5-32.8 12.5-45.3 0l-41.4-41.4 0 224c0 35.3-28.7 64-64 64l-192 0c-35.3 0-64-28.7-64-64l0-224-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0L22.9 214.6c-12.5-12.5-12.5-32.8 0-45.3L141.5 50.7c12-12 28.3-18.7 45.3-18.7l53.5 0c0 44.2 35.8 80 80 80z"
]
},
{
"kind": "fa",
"key": "fa:bed",
"name": "bed",
"label": "Bed",
"viewBox": "0 0 576 512",
"paths": [
"M32 32c17.7 0 32 14.3 32 32l0 224 224 0 0-128c0-17.7 14.3-32 32-32l160 0c53 0 96 43 96 96l0 224c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64-448 0 0 64c0 17.7-14.3 32-32 32S0 465.7 0 448L0 64C0 46.3 14.3 32 32 32zm80 160a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"
]
},
{
"kind": "fa",
"key": "fa:couch",
"name": "couch",
"label": "Tidy",
"viewBox": "0 0 640 512",
"paths": [
"M144 272C144 224.7 109.8 185.4 64.8 177.5 72 113.6 126.2 64 192 64l256 0c65.8 0 120 49.6 127.2 113.5-45 8-79.2 47.2-79.2 94.5l0 32-352 0 0-32zM0 384L0 272c0-26.5 21.5-48 48-48s48 21.5 48 48l0 80 448 0 0-80c0-26.5 21.5-48 48-48s48 21.5 48 48l0 112c0 35.3-28.7 64-64 64L64 448c-35.3 0-64-28.7-64-64z"
]
},
{
"kind": "fa",
"key": "fa:bath",
"name": "bath",
"label": "Bath",
"viewBox": "0 0 512 512",
"paths": [
"M96 77.3c0-7.3 5.9-13.3 13.3-13.3 3.5 0 6.9 1.4 9.4 3.9l14.9 14.9c-3.6 9.1-5.5 18.9-5.5 29.2 0 19.9 7.2 38 19.2 52-5.3 9.2-4 21.1 3.8 29 9.4 9.4 24.6 9.4 33.9 0L289 89c9.4-9.4 9.4-24.6 0-33.9-7.8-7.9-19.8-9.1-29-3.8-14-12-32.1-19.2-52-19.2-10.3 0-20.2 2-29.2 5.5L163.9 22.6C149.4 8.1 129.7 0 109.3 0 66.6 0 32 34.6 32 77.3L32 256c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 48c0 28.4 12.4 54 32 71.6L64 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-16 256 0 0 16c0 17.7 14.3 32 32 32s32-14.3 32-32l0-40.4c19.6-17.6 32-43.1 32-71.6l0-48c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 256 96 77.3z"
]
},
{
"kind": "fa",
"key": "fa:tooth",
"name": "tooth",
"label": "Teeth",
"viewBox": "0 0 448 512",
"paths": [
"M145 5.7L224 32 303 5.7C314.3 1.9 326 0 337.9 0 398.7 0 448 49.3 448 110.1l0 68.5c0 29.4-9.5 58.1-27.2 81.6l-1.1 1.5c-12.9 17.2-21.3 37.4-24.3 58.7L373.7 471.9c-3.3 23-23 40.1-46.2 40.1-22.8 0-42.3-16.5-46-39L261.3 351.6c-3-18.2-18.8-31.6-37.3-31.6s-34.2 13.4-37.3 31.6L166.5 473c-3.8 22.5-23.2 39-46 39-23.2 0-42.9-17.1-46.2-40.1L52.6 320.5c-3-21.3-11.4-41.5-24.3-58.7l-1.1-1.5C9.5 236.7 0 208.1 0 178.7l0-68.5C0 49.3 49.3 0 110.1 0 122 0 133.7 1.9 145 5.7z"
]
},
{
"kind": "fa",
"key": "fa:utensils",
"name": "utensils",
"label": "Meal",
"viewBox": "0 0 512 512",
"paths": [
"M63.9 14.4C63.1 6.2 56.2 0 48 0s-15.1 6.2-16 14.3L17.9 149.7c-1.3 6-1.9 12.1-1.9 18.2 0 45.9 35.1 83.6 80 87.7L96 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-224.4c44.9-4.1 80-41.8 80-87.7 0-6.1-.6-12.2-1.9-18.2L223.9 14.3C223.1 6.2 216.2 0 208 0s-15.1 6.2-15.9 14.4L178.5 149.9c-.6 5.7-5.4 10.1-11.1 10.1-5.8 0-10.6-4.4-11.2-10.2L143.9 14.6C143.2 6.3 136.3 0 128 0s-15.2 6.3-15.9 14.6L99.8 149.8c-.5 5.8-5.4 10.2-11.2 10.2-5.8 0-10.6-4.4-11.1-10.1L63.9 14.4zM448 0C432 0 320 32 320 176l0 112c0 35.3 28.7 64 64 64l32 0 0 128c0 17.7 14.3 32 32 32s32-14.3 32-32l0-448c0-17.7-14.3-32-32-32z"
]
},
{
"kind": "fa",
"key": "fa:bowl-food",
"name": "bowl-food",
"label": "Set the table",
"viewBox": "0 0 512 512",
"paths": [
"M0 176c0-35.3 28.7-64 64-64 .5 0 1.1 0 1.6 0 7.4-36.5 39.7-64 78.4-64 15 0 29 4.1 40.9 11.2 13.3-25.7 40.1-43.2 71.1-43.2s57.8 17.6 71.1 43.2c12-7.1 26-11.2 40.9-11.2 38.7 0 71 27.5 78.4 64 .5 0 1.1 0 1.6 0 35.3 0 64 28.7 64 64 0 11.7-3.1 22.6-8.6 32L8.6 208C3.1 198.6 0 187.7 0 176zM0 283.4C0 268.3 12.3 256 27.4 256l457.1 0c15.1 0 27.4 12.3 27.4 27.4 0 70.5-44.4 130.7-106.7 154.1L403.5 452c-2 16-15.6 28-31.8 28l-231.5 0c-16.1 0-29.8-12-31.8-28l-1.8-14.4C44.4 414.1 0 353.9 0 283.4z"
]
},
{
"kind": "fa",
"key": "fa:basket-shopping",
"name": "basket-shopping",
"label": "Groceries",
"viewBox": "0 0 576 512",
"paths": [
"M288 0c6.6 0 12.9 2.7 17.4 7.5l144 152 .5 .5 78.1 0c17.7 0 32 14.3 32 32 0 14.5-9.6 26.7-22.8 30.7L491.1 429.9c-6.5 29.3-32.5 50.1-62.5 50.1l-281.3 0c-30 0-56-20.8-62.5-50.1l-46-207.2c-13.2-3.9-22.8-16.2-22.8-30.7 0-17.7 14.3-32 32-32l78.1 0 .5-.5 144-152C275.1 2.7 281.4 0 288 0zm0 58.9L192.2 160 383.8 160 288 58.9zM208 264c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112zm80-24c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm128 24c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112z"
]
},
{
"kind": "fa",
"key": "fa:trash-can",
"name": "trash-can",
"label": "Bins",
"viewBox": "0 0 448 512",
"paths": [
"M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"
]
},
{
"kind": "fa",
"key": "fa:recycle",
"name": "recycle",
"label": "Recycling",
"viewBox": "0 0 512 512",
"paths": [
"M152.3 60C198.5-20 314-20 360.2 60l37.3 64.6 27.7-16c8.4-4.9 18.9-4.2 26.6 1.7s11.1 15.9 8.6 25.3L436.9 223c-3.4 12.8-16.6 20.4-29.4 17l-87.4-23.4c-9.4-2.5-16.3-10.4-17.6-20s3.4-19.1 11.8-23.9l27.7-16-37.3-64.6c-21.6-37.3-75.4-37.3-97 0l-5.3 9.1c-8.8 15.3-28.4 20.5-43.7 11.7S138.2 84.5 147 69.1l5.3-9.1zM449.7 279.1c15.3-8.8 34.9-3.6 43.7 11.7l5.3 9.1c46.2 80-11.5 180-103.9 180l-74.6 0 0 32c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-64-64c-9.4-9.4-9.4-24.6 0-33.9l64-64c6.9-6.9 17.2-8.9 26.2-5.2s14.8 12.5 14.8 22.2l0 32 74.6 0c43.1 0 70.1-46.7 48.5-84l-5.3-9.1c-8.8-15.3-3.6-34.9 11.7-43.7zM51 235.4l-27.7-16c-8.4-4.9-13.1-14.3-11.8-23.9s8.2-17.5 17.6-20L116.5 152c12.8-3.4 26 4.2 29.4 17l23.4 87.4c2.5 9.4-.9 19.3-8.6 25.3s-18.2 6.6-26.6 1.7l-27.7-16-37.3 64.6c-21.6 37.3 5.4 84 48.5 84l10.6 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-10.6 0C25.3 480-32.4 380 13.8 300L51 235.4z"
]
},
{
"kind": "fa",
"key": "fa:dog",
"name": "dog",
"label": "Dog",
"viewBox": "0 0 576 512",
"paths": [
"M32 112c16.6 0 30.2 12.6 31.8 28.7l.3 6.6C65.8 163.4 79.4 176 96 176l179.1 0 140.9 60.4 0 243.6c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-131.3C296 361 268.8 368 240 368s-56-7-80-19.3L160 480c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-245.6c-37.3-13.2-64-48.6-64-90.4 0-17.7 14.3-32 32-32zM355.8-32c7.7 0 14.9 3.6 19.6 9.8L392 0 444.1 0c12.7 0 24.9 5.1 33.9 14.1L496 32 552 32c13.3 0 24 10.7 24 24l0 24c0 44.2-35.8 80-80 80l-64 0-7 28-124.7-53.4 31.6-147.2C334.3-23.9 344.2-32 355.8-32zM448 44a20 20 0 1 0 0 40 20 20 0 1 0 0-40z"
]
},
{
"kind": "fa",
"key": "fa:cat",
"name": "cat",
"label": "Cat",
"viewBox": "0 0 576 512",
"paths": [
"M64 96c53 0 96 43 96 96l0 85.8c29.7-44.7 77.8-76.2 133.4-84 25.6 60 85.2 102.1 154.6 102.1 10.9 0 21.6-1.1 32-3.1L480 480c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-140.8-136 108.8 56 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-144 0c-53 0-96-43-96-96l0-224c0-16.6-12.6-30.2-28.7-31.8l-6.6-.3C44.6 158.2 32 144.6 32 128 32 110.3 46.3 96 64 96zM533.8 3.2C544.2-5.5 560 1.9 560 15.5L560 128c0 61.9-50.1 112-112 112S336 189.9 336 128l0-112.5c0-13.6 15.8-21 26.2-12.3L416 48 480 48 533.8 3.2zM400 108a20 20 0 1 0 0 40 20 20 0 1 0 0-40zm96 0a20 20 0 1 0 0 40 20 20 0 1 0 0-40z"
]
},
{
"kind": "fa",
"key": "fa:paw",
"name": "paw",
"label": "Pets",
"viewBox": "0 0 512 512",
"paths": [
"M234.5 92.9c14.3 42.9-.3 86.2-32.6 96.8s-70.1-15.6-84.4-58.5 .3-86.2 32.6-96.8 70.1 15.6 84.4 58.5zM100.4 198.6c18.9 32.4 14.3 70.1-10.2 84.1s-59.7-.9-78.5-33.3-14.3-70.1 10.2-84.1 59.7 .9 78.5 33.3zM69.2 401.2C121.6 259.9 214.7 224 256 224s134.4 35.9 186.8 177.2c3.6 9.7 5.2 20.1 5.2 30.5l0 1.6c0 25.8-20.9 46.7-46.7 46.7-11.5 0-22.9-1.4-34-4.2l-88-22c-15.3-3.8-31.3-3.8-46.6 0l-88 22c-11.1 2.8-22.5 4.2-34 4.2-25.8 0-46.7-20.9-46.7-46.7l0-1.6c0-10.4 1.6-20.8 5.2-30.5zM421.8 282.7c-24.5-14-29.1-51.7-10.2-84.1s54-47.3 78.5-33.3 29.1 51.7 10.2 84.1-54 47.3-78.5 33.3zM310.1 189.7c-32.3-10.6-46.9-53.9-32.6-96.8s52.1-69.1 84.4-58.5 46.9 53.9 32.6 96.8-52.1 69.1-84.4 58.5z"
]
},
{
"kind": "fa",
"key": "fa:seedling",
"name": "seedling",
"label": "Plants",
"viewBox": "0 0 512 512",
"paths": [
"M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"
]
},
{
"kind": "fa",
"key": "fa:book",
"name": "book",
"label": "Reading",
"viewBox": "0 0 448 512",
"paths": [
"M384 512L96 512c-53 0-96-43-96-96L0 96C0 43 43 0 96 0L400 0c26.5 0 48 21.5 48 48l0 288c0 20.9-13.4 38.7-32 45.3l0 66.7c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zM96 384c-17.7 0-32 14.3-32 32s14.3 32 32 32l256 0 0-64-256 0zm32-232c0 13.3 10.7 24 24 24l176 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-176 0c-13.3 0-24 10.7-24 24zm24 72c-13.3 0-24 10.7-24 24s10.7 24 24 24l176 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-176 0z"
]
}
];
export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
/** Mirrors core/icons.py EMOJI_MAX_CODE_POINTS. */
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]));
function isEmojiPayload(text: string): boolean {
const codePoints = [...text];
if (codePoints.length === 0 || codePoints.length > EMOJI_MAX_CODE_POINTS) return false;
return !codePoints.some((char) => /[\s<>&]|\p{Cc}/u.test(char));
}
/**
* 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
* `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.
*/
export function resolveIcon(value: string | null | undefined): ResolvedIcon {
if (!value || !value.trim()) return FALLBACK_ICON;
const colon = value.indexOf(":");
if (colon === -1) return LUCIDE_BY_NAME.get(value) ?? FALLBACK_ICON;
const namespace = value.slice(0, colon);
const name = value.slice(colon + 1);
if (namespace === "lucide") return LUCIDE_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "fa") return FA_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "emoji" && isEmojiPayload(name)) return { kind: "emoji", key: value, text: name };
return FALLBACK_ICON;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* 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.
*/
import { html, svg, type TemplateResult } from "lit";
import { resolveIcon } from "./registry.generated";
/**
* A stored definition icon (`lucide:<name>`, `fa:<name>`, `emoji:<grapheme>`
* or a legacy bare Lucide name) as a template; anything that resolves to the
* registry fallback renders `fallback` instead.
*/
export function renderQuestIcon(
value: string | null,
fallback: TemplateResult
): TemplateResult {
const icon = resolveIcon(value);
switch (icon.kind) {
case "lucide":
return html`<svg
viewBox=${icon.viewBox}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-icon=${icon.key}
>
${icon.paths.map((d) => svg`<path d=${d}></path>`)}
</svg>`;
case "fa":
return html`<svg
viewBox=${icon.viewBox}
fill="currentColor"
aria-hidden="true"
data-icon=${icon.key}
>
${icon.paths.map((d) => svg`<path d=${d}></path>`)}
</svg>`;
case "emoji":
return html`<span class="emoji" data-icon=${icon.key}>${icon.text}</span>`;
case "fallback":
return fallback;
}
}
+20 -4
View File
@@ -7,10 +7,10 @@
* the card is mounted at the given URL (it resolves its child from the
* pathname's last segment), after the optional probe wait one snapshot of
* the render is taken mid-wait, then after the optional idle wait stdout
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* receives one JSON object {location, headline, countdown, html, tiles,
* probe} — the final window pathname, the rendered title, the
* complete-screen countdown text, the rendered shadow DOM, each quest
* card's icon tile, and the probe snapshot when probe_ms was given. The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
@@ -56,6 +56,22 @@ const snapshot = () => ({
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
"",
html: element.shadowRoot.innerHTML,
// Each quest card's icon tile: the registry glyph it rendered (its
// data-icon key and svg fill), its text (an emoji), and whether it shows
// the static star (the star's polygon).
tiles: [...element.shadowRoot.querySelectorAll(".quest")].map((quest) => {
const tile = quest.querySelector(".tile");
const glyph = tile?.querySelector("[data-icon]");
return {
id: Number(quest.dataset.instanceId),
sealed: quest.classList.contains("sealed"),
icon: glyph?.getAttribute("data-icon") ?? null,
fill: glyph?.getAttribute("fill") ?? null,
paths: tile?.querySelectorAll("path").length ?? 0,
text: tile?.textContent?.trim() ?? "",
star: Boolean(tile?.querySelector("polygon")),
};
}),
});
let probe = null;
+87 -3
View File
@@ -35,6 +35,7 @@ for malformed bodies.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
@@ -223,7 +224,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
}
assert payload["title"] == "Tidy the den"
assert payload["description"] == "Put everything back where it belongs."
assert payload["icon"] == "toy-box"
assert payload["icon"] == "lucide:toy-box"
assert payload["is_active"] is True
assert payload["skip_on_away"] is True
assert payload["rule"] == _weekly_rule_dict()
@@ -245,7 +246,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.definition.is_active is True
assert stored.rule.to_dict() == _weekly_rule_dict()
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -396,7 +397,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
assert stored.definition.description == (
"Put everything back where it belongs."
)
assert stored.definition.icon == "toy-box"
assert stored.definition.icon == "lucide:toy-box"
assert stored.rule.to_dict() == _weekly_rule_dict()
# assignee_child_ids was omitted: the assignees are untouched.
assert sorted(child.id for child in stored.assignees) == [ada, bo]
@@ -468,6 +469,89 @@ async def test_edit_can_clear_description_and_icon_explicitly(
assert stored.rule.to_dict() == _weekly_rule_dict()
async def test_legacy_bare_icon_reads_back_namespaced(
admin_questdefs: SimpleNamespace,
) -> None:
"""A row stored with a pre-namespacing bare icon reads as ``lucide:``.
The row is rewritten through the DAO (bypassing the write-side
validation, as a row from before namespacing would be) and then read
back through BOTH consumers: the admin definition response and the
kids' panel snapshot's per-instance ``icon``.
"""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
today = datetime.date.today().isoformat()
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": {"rule_type": "daily", "start_date": today},
"assignee_child_ids": [ada],
"windows": ["evening"],
"icon": "fa:dog",
},
headers=_admin_headers(),
)
assert created.status_code == 201
definition_id = created.json()["id"]
await _dao_rules.QuestDefinitionsDao(
admin_questdefs.database
).edit_definition(definition_id, icon="dog")
stored = (await _definitions_by_id(admin_questdefs.database))[
definition_id
]
assert stored.definition.icon == "dog"
listed = await client.get(
"/api/v1/admin/quest-definitions", headers=_admin_headers()
)
assert listed.status_code == 200
[definition] = [
d for d in listed.json()["definitions"] if d["id"] == definition_id
]
assert definition["icon"] == "lucide:dog"
panel = await client.get(
"/api/v1/panel/snapshot",
headers={"Authorization": "Bearer admin-test-panel-token"},
)
assert panel.status_code == 200
[child] = panel.json()["children"]
icons = {
instance["icon"]
for instance in child["instances"]
if instance["definition_id"] == definition_id
}
assert icons == {"lucide:dog"}
@pytest.mark.parametrize(
"icon", ["foo:dog", "lucide:", "emoji:<b>", "javascript:alert(1)"]
)
async def test_create_invalid_icon_is_422_naming_icon(
admin_questdefs: SimpleNamespace, icon: str
) -> None:
"""An invalid icon key is a 422 whose detail names the icon field."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Walk the dog",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"icon": icon,
},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "icon" in str(response.json()["detail"])
assert await _definitions_by_id(admin_questdefs.database) == {}
async def test_edit_unknown_definition_is_404(
admin_questdefs: SimpleNamespace,
) -> None:
+1 -1
View File
@@ -135,7 +135,7 @@ async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
"id": active_id,
"title": "Tidy the den",
"description": "Put everything back where it belongs.",
"icon": "toy-box",
"icon": "lucide:toy-box",
"is_active": True,
"skip_on_away": True,
"rule": {
+152
View File
@@ -0,0 +1,152 @@
"""Test the namespaced icon-key module (task 707994b7).
:func:`validate_icon` is the strict write-side check (create/edit);
:func:`normalize_icon_for_read` the lenient read-side normalizer that
lets legacy bare Lucide names render without a data migration.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from custom_components.nestquest.core.icons import (
EMOJI_MAX_CODE_POINTS,
ICON_NAMESPACES,
normalize_icon_for_read,
validate_icon,
)
def test_namespace_and_cap_constants() -> None:
assert ICON_NAMESPACES == {"lucide", "fa", "emoji"}
assert EMOJI_MAX_CODE_POINTS == 8
@pytest.mark.parametrize(
("value", "expected"),
[
("lucide:dog", "lucide:dog"),
("fa:dog", "fa:dog"),
("fa:paw-print", "fa:paw-print"),
("lucide:toy-box-2", "lucide:toy-box-2"),
("emoji:🐕", "emoji:🐕"),
# A ZWJ family sequence (7 code points) is within the cap.
("emoji:👨‍👩‍👧‍👦", "emoji:👨‍👩‍👧‍👦"),
("emoji:" + "⭐" * EMOJI_MAX_CODE_POINTS, "emoji:" + "⭐" * 8),
# Legacy bare Lucide names normalize to the lucide namespace.
("dog", "lucide:dog"),
],
)
def test_validate_icon_accepts(value: str, expected: str) -> None:
assert validate_icon(value) == expected
@pytest.mark.parametrize("value", [None, "", " ", "\t\n"])
def test_validate_icon_clears(value: str | None) -> None:
assert validate_icon(value) is None
@pytest.mark.parametrize(
"value",
[
"foo:dog", # unknown namespace
"javascript:alert(1)", # unknown namespace (and markup-ish)
"mdi:dog", # not one of the supported namespaces
"lucide:", # empty name
"fa:", # empty name
"emoji:", # empty payload
"lucide:Dog", # uppercase
"lucide:has space", # whitespace
"lucide:-dog", # leading hyphen
"lucide:dog-", # trailing hyphen
"lucide:dog_bone", # not kebab
"fa:dög", # non-ASCII
"lucide:a:b", # colon inside the name
"Dog", # bare legacy name must still be kebab
"has space",
"<script>",
"emoji:" + "⭐" * (EMOJI_MAX_CODE_POINTS + 1), # over the cap
"emoji:a b", # whitespace
"emoji:<b>", # markup
"emoji:a&b", # entity start
"emoji:🐕\x00", # control character
"emoji:🐕\x07🐕", # control character between emoji
"emoji:\ufeff", # U+FEFF BOM: JS \s matches it, so reject to agree
"emoji:🐕\ufeff", # trailing U+FEFF
# Edge whitespace/controls are part of the value, never trimmed away.
"emoji:🐕 ", # trailing space
"emoji:🐕\n", # trailing newline
"emoji:🐕\x1c", # trailing control character (str.strip() eats it)
" emoji:🐕", # leading space
"lucide:dog ", # trailing space
" fa:dog ", # padded namespaced key
" toy-box ", # padded legacy bare name
],
)
def test_validate_icon_rejects_naming_the_field(value: str) -> None:
with pytest.raises(ValueError, match=r"^icon\b"):
validate_icon(value)
@pytest.mark.parametrize("value", [42, True, ["dog"], {"icon": "dog"}])
def test_validate_icon_rejects_non_strings(value: object) -> None:
with pytest.raises(ValueError, match="icon must be a string"):
validate_icon(value)
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("", None),
(" ", None),
("lucide:dog", "lucide:dog"),
("fa:dog", "fa:dog"),
("emoji:🐕", "emoji:🐕"),
# Legacy bare names (and anything else) fall through to lucide so
# the frontend's fallback handles an unknown name, never an error.
("dog", "lucide:dog"),
("mdi:tooth", "lucide:mdi:tooth"),
("Not Kebab", "lucide:Not Kebab"),
],
)
def test_normalize_icon_for_read(value: str | None, expected: str | None) -> None:
assert normalize_icon_for_read(value) == expected
# The emoji-payload rule is duplicated in the generated JS resolver
# (``isEmojiPayload`` in tools/icons/generate.mjs). Both suites assert
# this one table — admin/src/icons/registry.test.ts runs it through
# resolveIcon — so a divergence between the runtimes fails a test.
_EMOJI_PARITY_CASES = json.loads(
(
Path(__file__).parent.parent / "tools" / "icons" / "emoji-payload-cases.json"
).read_text(encoding="utf-8")
)["cases"]
@pytest.mark.parametrize(
"case", _EMOJI_PARITY_CASES, ids=[case["why"] for case in _EMOJI_PARITY_CASES]
)
def test_validate_icon_emoji_verdict_matches_shared_table(case: dict) -> None:
value = "emoji:" + case["payload"]
if case["valid"]:
assert validate_icon(value) == value
else:
with pytest.raises(ValueError, match=r"^icon\b"):
validate_icon(value)
def test_emoji_parity_table_covers_the_contract_edges() -> None:
payloads = {case["payload"]: case["valid"] for case in _EMOJI_PARITY_CASES}
assert payloads["\ufeff"] is False
assert payloads["🐕 "] is False
assert payloads["🐕\x00"] is False
assert payloads["<b>"] is False and payloads["a>b"] is False
assert payloads["a&b"] is False
assert payloads["⭐" * EMOJI_MAX_CODE_POINTS] is True
assert payloads["⭐" * (EMOJI_MAX_CODE_POINTS + 1)] is False
family = "👨\u200d👩\u200d👧\u200d👦"
assert payloads[family] is True
+94
View File
@@ -0,0 +1,94 @@
"""The shared quest-icon registry: one curated source, two identical artefacts.
``tools/icons/curated-icons.json`` is the only hand-edited list; the offline
generator ``tools/icons/generate.mjs`` writes the same module into the admin
PWA and the panel bundle. The admin Vitest suite regenerates and compares
byte-for-byte; these checks need no Node for the core guarantees.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
SOURCE = REPO_ROOT / "tools" / "icons" / "curated-icons.json"
GENERATOR = REPO_ROOT / "tools" / "icons" / "generate.mjs"
ARTEFACTS = (
REPO_ROOT / "admin" / "src" / "icons" / "registry.generated.ts",
REPO_ROOT / "frontend" / "src" / "icons" / "registry.generated.ts",
)
PANEL_BUNDLE = (
REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
)
def _curated() -> dict[str, list[dict[str, str]]]:
return json.loads(SOURCE.read_text(encoding="utf-8"))
def _generated_set(text: str, const: str) -> list[dict]:
match = re.search(
rf"export const {const}: readonly GlyphIcon\[\] = (\[.*?\n\]);",
text,
re.DOTALL,
)
assert match, f"{const} not found in the generated registry"
return json.loads(match.group(1))
def test_both_artefacts_are_byte_identical() -> None:
admin, frontend = (path.read_bytes() for path in ARTEFACTS)
assert admin == frontend
def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
curated = _curated()
text = ARTEFACTS[0].read_text(encoding="utf-8")
for namespace, const in (("lucide", "LUCIDE_ICONS"), ("fa", "FA_ICONS")):
entries = _generated_set(text, const)
assert [
{"name": e["name"], "label": e["label"]} for e in entries
] == curated[namespace]
for entry in entries:
assert entry["kind"] == namespace
assert entry["key"] == f"{namespace}:{entry['name']}"
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
assert entry["paths"] and all(entry["paths"])
assert len(curated["lucide"]) == 36
assert 15 <= len(curated["fa"]) <= 25
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")
assert not re.search(r"https?://", text, re.IGNORECASE), path
assert not re.search(
r"\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome", text, re.IGNORECASE
), path
assert "data-lucide" not in text, path
def test_panel_bundle_embeds_the_registry() -> None:
bundle = PANEL_BUNDLE.read_text(encoding="utf-8")
assert "fa:broom" in bundle
assert "lucide:dog" in bundle
assert "data-lucide" not in bundle
def test_generator_reproduces_the_committed_artefacts() -> None:
node = shutil.which("node")
if node is None or not (REPO_ROOT / "admin" / "node_modules").is_dir():
pytest.skip("needs node and admin/node_modules (npm ci in admin/)")
result = subprocess.run(
[node, str(GENERATOR), "--check"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+112
View File
@@ -886,3 +886,115 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"
# --- Quest card icons (definition icon on the open tile) ---------------------
def _quest_row(instance_id: int, icon: str | None, state: str = "open") -> dict:
"""One ``instances`` attribute row for Ada's quests-due sensor."""
row = {
"id": instance_id,
"child_id": 1,
"title": f"Quest {instance_id}",
"icon": icon,
"window": "morning",
"due_time": "08:00",
"state": state,
"overdue": False,
"completed_at": None,
"on_time": None,
}
if state == "completed":
row["completed_at"] = "2026-09-26T07:30:00+00:00"
row["on_time"] = True
return row
def _quest_log_tiles(rows: list[dict]) -> dict[int, dict]:
"""Render Ada's quest log with ``rows`` and key its tiles by instance id."""
states = _three_child_states()
states["sensor.nestquest_ada_quests_due_today"] = _state(
str(len(rows)), child_name="Ada", present=True, instances=rows
)
result = _render_quest_log(
{"type": "custom:nestquest-quest-log-card"},
states,
url="http://homeassistant.local/nestquest/ada",
)
return {tile["id"]: tile for tile in result["tiles"]}
def test_open_quest_tile_renders_the_definition_icon() -> None:
"""The open card's tile shows the definition's namespaced icon, drawn
from the bundled registry: Lucide as a stroked svg, Font Awesome as a
filled svg, an emoji as its text, and a legacy bare name as Lucide."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:bed"),
_quest_row(2, "fa:broom"),
_quest_row(3, "emoji:🦊"),
_quest_row(4, "bath"),
]
)
assert tiles[1]["icon"] == "lucide:bed"
assert tiles[1]["fill"] == "none"
assert tiles[1]["paths"] > 0
assert tiles[2]["icon"] == "fa:broom"
assert tiles[2]["fill"] == "currentColor"
assert tiles[2]["paths"] > 0
assert tiles[3]["icon"] == "emoji:🦊"
assert tiles[3]["text"] == "🦊"
assert tiles[4]["icon"] == "lucide:bath"
assert tiles[4]["fill"] == "none"
for tile in tiles.values():
assert not tile["sealed"]
assert not tile["star"]
def test_open_quest_tile_falls_back_to_the_star() -> None:
"""An unknown name, an unknown namespace, or no icon at all keeps the
open card's current star glyph."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:not-a-real-icon"),
_quest_row(2, "fa:not-a-real-icon"),
_quest_row(3, "mdi:bed"),
_quest_row(4, ""),
_quest_row(5, None),
]
)
assert set(tiles) == {1, 2, 3, 4, 5}
for tile in tiles.values():
assert tile["star"]
assert tile["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."""
tiles = _quest_log_tiles(
[
_quest_row(1, "lucide:bed", state="completed"),
_quest_row(2, "fa:broom", state="completed"),
_quest_row(3, "emoji:🦊", state="completed"),
]
)
for tile in tiles.values():
assert tile["sealed"]
assert tile["star"]
assert tile["icon"] is None
assert tile["text"] == ""
def test_only_the_open_tile_calls_the_icon_resolver() -> None:
source = _read(QUEST_LOG)
sealed_start = source.index('<div class="quest sealed"')
sealed_block = source[sealed_start : source.index("</div>", sealed_start)]
assert '<span class="tile" aria-hidden="true">${ICON_STAR}</span>' in sealed_block
assert "renderQuestIcon" not in sealed_block
assert (
source.count("renderQuestIcon(instance.icon, ICON_STAR)") == 1
), "only the open card's tile resolves the definition icon"
open_start = source.index('class="quest tappable"')
assert "renderQuestIcon(instance.icon, ICON_STAR)" in source[open_start:]
+90 -7
View File
@@ -106,13 +106,13 @@ def test_create_one_child_one_window(tmp_path) -> None:
[child.id],
["morning"],
description=" Twice daily ",
icon=" mdi:tooth ",
icon="lucide:tooth",
)
assert isinstance(result, CreatedQuestDefinition)
definition = result.definition
assert definition.title == "Brush teeth"
assert definition.description == "Twice daily"
assert definition.icon == "mdi:tooth"
assert definition.icon == "lucide:tooth"
assert definition.is_active is True
# D-008: the definition-level due_time column is never set here.
assert definition.due_time is None
@@ -232,6 +232,89 @@ def test_create_rejects_non_string_title(tmp_path) -> None:
_with_db(tmp_path, "create-bad-title-type.db")(_body)
@pytest.mark.parametrize(
("icon", "stored"),
[
("lucide:dog", "lucide:dog"),
("fa:dog", "fa:dog"),
("emoji:🐕", "emoji:🐕"),
("dog", "lucide:dog"), # legacy bare name: canonical lucide key
],
)
def test_create_stores_canonical_icon(tmp_path, icon, stored) -> None:
async def _body(database, children, rules, definitions, child):
result = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon=icon,
)
assert result.definition.icon == stored
row = await definitions.get(result.definition.id)
assert row is not None
assert row.icon == stored
return None
_with_db(tmp_path, "create-icon.db")(_body)
_BAD_ICONS = [
"foo:dog",
"lucide:",
"lucide:Dog",
"lucide:has space",
"emoji:" + "🐕" * 9,
"emoji:a b",
"emoji:<b>",
"javascript:alert(1)",
]
@pytest.mark.parametrize("bad", _BAD_ICONS)
def test_create_rejects_invalid_icon_and_persists_nothing(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
with pytest.raises(ValueError, match=r"^icon\b"):
await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon=bad,
)
assert await definitions.list_active() == []
return None
_with_db(tmp_path, "create-bad-icon.db")(_body)
@pytest.mark.parametrize("bad", _BAD_ICONS)
def test_edit_rejects_invalid_icon_and_changes_nothing(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
icon="lucide:dog",
)
with pytest.raises(ValueError, match=r"^icon\b"):
await edit_quest_definition(
database, created.definition.id, icon=bad
)
row = await definitions.get(created.definition.id)
assert row is not None
assert row.icon == "lucide:dog"
return None
_with_db(tmp_path, "edit-bad-icon.db")(_body)
def test_edit_normalizes_legacy_bare_icon(tmp_path) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Walk dog", _daily_rule(), [child.id], ["morning"],
)
result = await edit_quest_definition(
database, created.definition.id, icon="dog"
)
assert result.definition.icon == "lucide:dog"
return None
_with_db(tmp_path, "edit-legacy-icon.db")(_body)
@pytest.mark.parametrize("bad", [None, "daily", 42, {"rule_type": "daily"}])
def test_create_rejects_non_schedule_rule(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
@@ -535,18 +618,18 @@ def test_edit_title_description_icon(tmp_path) -> None:
[child.id],
["morning"],
description="Old description",
icon="mdi:old",
icon="lucide:old",
)
result = await edit_quest_definition(
database,
created.definition.id,
title=" Floss ",
description=" New description ",
icon=" mdi:new ",
icon="fa:new",
)
assert result.definition.title == "Floss"
assert result.definition.description == "New description"
assert result.definition.icon == "mdi:new"
assert result.definition.icon == "fa:new"
# Untouched fields survive the edit unchanged.
assert result.definition.id == created.definition.id
assert result.definition.schedule_rule_id == created.definition.schedule_rule_id
@@ -561,7 +644,7 @@ def test_edit_title_description_icon(tmp_path) -> None:
assert stored is not None
assert stored.title == "Floss"
assert stored.description == "New description"
assert stored.icon == "mdi:new"
assert stored.icon == "fa:new"
return None
_with_db(tmp_path, "edit-metadata.db")(_body)
@@ -597,7 +680,7 @@ def test_edit_clears_optional_metadata(tmp_path) -> None:
[child.id],
["morning"],
description="Old",
icon="mdi:old",
icon="lucide:old",
)
result = await edit_quest_definition(
database,
+64
View File
@@ -0,0 +1,64 @@
{
"lucide": [
{ "name": "bed", "label": "Bed" },
{ "name": "bath", "label": "Bath" },
{ "name": "shower-head", "label": "Shower" },
{ "name": "toilet", "label": "Toilet" },
{ "name": "brush", "label": "Brush" },
{ "name": "smile", "label": "Smile" },
{ "name": "shirt", "label": "Clothes" },
{ "name": "washing-machine", "label": "Laundry" },
{ "name": "utensils", "label": "Meal" },
{ "name": "cooking-pot", "label": "Cooking" },
{ "name": "apple", "label": "Snack" },
{ "name": "brush-cleaning", "label": "Sweep" },
{ "name": "spray-can", "label": "Clean" },
{ "name": "trash-2", "label": "Bins" },
{ "name": "recycle", "label": "Recycling" },
{ "name": "sofa", "label": "Tidy" },
{ "name": "house", "label": "Home" },
{ "name": "backpack", "label": "School bag" },
{ "name": "book-open", "label": "Reading" },
{ "name": "pencil", "label": "Homework" },
{ "name": "music", "label": "Music" },
{ "name": "dog", "label": "Dog" },
{ "name": "cat", "label": "Cat" },
{ "name": "fish", "label": "Fish" },
{ "name": "sprout", "label": "Plants" },
{ "name": "droplets", "label": "Water" },
{ "name": "bike", "label": "Bike" },
{ "name": "volleyball", "label": "Sport" },
{ "name": "gamepad-2", "label": "Games" },
{ "name": "star", "label": "Star" },
{ "name": "heart", "label": "Heart" },
{ "name": "sun", "label": "Morning" },
{ "name": "moon", "label": "Bedtime" },
{ "name": "clock", "label": "Clock" },
{ "name": "calendar", "label": "Calendar" },
{ "name": "list-checks", "label": "Checklist" }
],
"fa": [
{ "name": "broom", "label": "Sweep" },
{ "name": "spray-can-sparkles", "label": "Clean" },
{ "name": "bucket", "label": "Mop" },
{ "name": "soap", "label": "Wash hands" },
{ "name": "sink", "label": "Dishes" },
{ "name": "jug-detergent", "label": "Laundry" },
{ "name": "socks", "label": "Socks" },
{ "name": "shirt", "label": "Clothes" },
{ "name": "bed", "label": "Bed" },
{ "name": "couch", "label": "Tidy" },
{ "name": "bath", "label": "Bath" },
{ "name": "tooth", "label": "Teeth" },
{ "name": "utensils", "label": "Meal" },
{ "name": "bowl-food", "label": "Set the table" },
{ "name": "basket-shopping", "label": "Groceries" },
{ "name": "trash-can", "label": "Bins" },
{ "name": "recycle", "label": "Recycling" },
{ "name": "dog", "label": "Dog" },
{ "name": "cat", "label": "Cat" },
{ "name": "paw", "label": "Pets" },
{ "name": "seedling", "label": "Plants" },
{ "name": "book", "label": "Reading" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"_comment": "Shared emoji-payload verdicts. tests/test_core_icons.py (validate_icon) and admin/src/icons/registry.test.ts (resolveIcon) both assert every row, so the Python and JS rules cannot diverge silently.",
"cases": [
{ "payload": "🐕", "valid": true, "why": "plain emoji" },
{ "payload": "👨‍👩‍👧‍👦", "valid": true, "why": "ZWJ family (U+200D allowed)" },
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐", "valid": true, "why": "exactly 8 code points" },
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐⭐", "valid": false, "why": "9 code points, over the cap" },
{ "payload": "", "valid": false, "why": "empty" },
{ "payload": "", "valid": false, "why": "U+FEFF BOM alone" },
{ "payload": "🐕", "valid": false, "why": "trailing U+FEFF" },
{ "payload": "🐕 ", "valid": false, "why": "trailing space" },
{ "payload": "a b", "valid": false, "why": "inner space" },
{ "payload": "🐕 ", "valid": false, "why": "no-break space" },
{ "payload": "🐕 ", "valid": false, "why": "ideographic space" },
{ "payload": "🐕
", "valid": false, "why": "line separator" },
{ "payload": "🐕\n", "valid": false, "why": "newline" },
{ "payload": "🐕\u0000", "valid": false, "why": "NUL control" },
{ "payload": "🐕\u0007🐕", "valid": false, "why": "BEL control" },
{ "payload": "🐕\u001c", "valid": false, "why": "file-separator control" },
{ "payload": "🐕\u0085", "valid": false, "why": "NEL C1 control" },
{ "payload": "🐕\u007f", "valid": false, "why": "DEL control" },
{ "payload": "<b>", "valid": false, "why": "markup <" },
{ "payload": "a>b", "valid": false, "why": "markup >" },
{ "payload": "a&b", "valid": false, "why": "entity start &" }
]
}
+240
View File
@@ -0,0 +1,240 @@
/**
* 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:
*
* admin/src/icons/registry.generated.ts
* frontend/src/icons/registry.generated.ts
*
* node tools/icons/generate.mjs # write both artefacts
* node tools/icons/generate.mjs --check # exit 1 if either artefact drifted
*
* Lucide circles, rects and lines are converted to path `d` strings so every
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
export const REPO_ROOT = join(HERE, "..", "..");
export const SOURCE_PATH = join(HERE, "curated-icons.json");
export const OUTPUT_PATHS = [
join(REPO_ROOT, "admin", "src", "icons", "registry.generated.ts"),
join(REPO_ROOT, "frontend", "src", "icons", "registry.generated.ts"),
];
const ADMIN_MODULES = join(REPO_ROOT, "admin", "node_modules");
const LUCIDE_ICON_DIR = join(ADMIN_MODULES, "lucide-react", "dist", "esm", "icons");
const requireFromAdmin = createRequire(join(REPO_ROOT, "admin", "package.json"));
function packageVersion(name) {
const pkg = JSON.parse(readFileSync(join(ADMIN_MODULES, name, "package.json"), "utf8"));
return pkg.version;
}
function num(value, what) {
const n = Number(value);
if (!Number.isFinite(n)) throw new Error(`${what}: not a number: ${value}`);
return n;
}
/** One Lucide icon-node element as a path `d` string. */
function elementToPath(tag, attrs, icon) {
const where = `lucide ${icon} <${tag}>`;
const known = { path: ["d"], circle: ["cx", "cy", "r"], rect: ["x", "y", "width", "height", "rx", "ry"], line: ["x1", "y1", "x2", "y2"] };
if (!(tag in known)) throw new Error(`${where}: unsupported element`);
for (const attr of Object.keys(attrs)) {
if (attr !== "key" && !known[tag].includes(attr)) throw new Error(`${where}: unsupported attribute ${attr}`);
}
if (tag === "path") return attrs.d;
if (tag === "line") {
return `M${num(attrs.x1, where)} ${num(attrs.y1, where)}L${num(attrs.x2, where)} ${num(attrs.y2, where)}`;
}
if (tag === "circle") {
const [cx, cy, r] = [num(attrs.cx, where), num(attrs.cy, where), num(attrs.r, where)];
return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
}
const [x, y, w, h] = [num(attrs.x ?? 0, where), num(attrs.y ?? 0, where), num(attrs.width, where), num(attrs.height, where)];
const rxRaw = attrs.rx ?? attrs.ry;
const ryRaw = attrs.ry ?? attrs.rx;
const rx = Math.min(rxRaw === undefined ? 0 : num(rxRaw, where), w / 2);
const ry = Math.min(ryRaw === undefined ? 0 : num(ryRaw, where), h / 2);
if (rx === 0 || ry === 0) return `M${x} ${y}h${w}v${h}h${-w}z`;
const iw = w - 2 * rx;
const ih = h - 2 * ry;
return (
`M${x + rx} ${y}h${iw}a${rx} ${ry} 0 0 1 ${rx} ${ry}v${ih}` +
`a${rx} ${ry} 0 0 1 ${-rx} ${ry}h${-iw}a${rx} ${ry} 0 0 1 ${-rx} ${-ry}` +
`v${-ih}a${rx} ${ry} 0 0 1 ${rx} ${-ry}z`
);
}
/** A Lucide icon's node list; follows lucide-react's alias re-exports (smile → face-slightly-smiling). */
async function lucideNode(name, seen = []) {
if (seen.includes(name)) throw new Error(`lucide ${name}: alias cycle ${seen.join(" -> ")}`);
const file = join(LUCIDE_ICON_DIR, `${name}.mjs`);
const mod = await import(pathToFileURL(file).href);
if (mod.__iconData) return mod.__iconData.node;
const alias = /export \{ default \} from '\.\/([a-z0-9-]+)\.mjs';/.exec(readFileSync(file, "utf8"));
if (!alias) throw new Error(`lucide ${name}: no icon data in ${file}`);
return lucideNode(alias[1], [...seen, name]);
}
function faExportName(name) {
return "fa" + name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
}
function checkCurated(list, namespace) {
const seen = new Set();
for (const { name, label } of list) {
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`${namespace}:${name}: not a kebab name`);
if (typeof label !== "string" || !label.trim()) throw new Error(`${namespace}:${name}: missing label`);
if (seen.has(name)) throw new Error(`${namespace}:${name}: duplicate`);
seen.add(name);
}
}
async function buildEntries() {
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8"));
checkCurated(curated.lucide, "lucide");
checkCurated(curated.fa, "fa");
const lucide = [];
for (const { name, label } of curated.lucide) {
const node = await lucideNode(name);
const paths = node.map(([tag, attrs]) => elementToPath(tag, attrs, name));
lucide.push({ kind: "lucide", key: `lucide:${name}`, name, label, viewBox: "0 0 24 24", paths });
}
const faPackage = requireFromAdmin("@fortawesome/free-solid-svg-icons");
const fa = [];
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 });
}
return { lucide, fa };
}
const RESOLVER = `
export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
/** Mirrors core/icons.py EMOJI_MAX_CODE_POINTS. */
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]));
function isEmojiPayload(text: string): boolean {
const codePoints = [...text];
if (codePoints.length === 0 || codePoints.length > EMOJI_MAX_CODE_POINTS) return false;
return !codePoints.some((char) => /[\\s<>&]|\\p{Cc}/u.test(char));
}
/**
* 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
* \`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.
*/
export function resolveIcon(value: string | null | undefined): ResolvedIcon {
if (!value || !value.trim()) return FALLBACK_ICON;
const colon = value.indexOf(":");
if (colon === -1) return LUCIDE_BY_NAME.get(value) ?? FALLBACK_ICON;
const namespace = value.slice(0, colon);
const name = value.slice(colon + 1);
if (namespace === "lucide") return LUCIDE_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "fa") return FA_BY_NAME.get(name) ?? FALLBACK_ICON;
if (namespace === "emoji" && isEmojiPayload(name)) return { kind: "emoji", key: value, text: name };
return FALLBACK_ICON;
}
`;
/** The generated module's full text (identical for both bundles). */
export async function renderRegistry() {
const { lucide, fa } = 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
// file differs from a fresh generation).
//
// Glyph path data is embedded at generation time; nothing is fetched at runtime.
// Lucide glyphs: lucide-react ${packageVersion("lucide-react")} (ISC licence).
// Font Awesome glyphs: @fortawesome/free-solid-svg-icons ${packageVersion("@fortawesome/free-solid-svg-icons")}
// (Font Awesome Free, icons under CC BY 4.0).
/** A vector glyph: draw each path in \`paths\` inside an <svg viewBox>. Lucide
* glyphs are stroked (fill none, stroke-width 2, round caps and joins);
* Font Awesome glyphs are filled. */
export interface GlyphIcon {
kind: "lucide" | "fa";
key: string;
name: string;
label: string;
viewBox: string;
paths: readonly string[];
}
/** An emoji icon; render \`text\` as text. */
export interface EmojiIcon {
kind: "emoji";
key: string;
text: string;
}
/** No usable icon: the caller draws its own default glyph. */
export interface FallbackIcon {
kind: "fallback";
key: null;
}
export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
export const LUCIDE_ICONS: readonly GlyphIcon[] = ${JSON.stringify(lucide, null, 2)};
export const FA_ICONS: readonly GlyphIcon[] = ${JSON.stringify(fa, null, 2)};
${RESOLVER}`;
}
async function main() {
const check = process.argv.includes("--check");
const text = await renderRegistry();
let drifted = false;
for (const path of OUTPUT_PATHS) {
const shown = relative(REPO_ROOT, path);
if (check) {
let current = null;
try {
current = readFileSync(path, "utf8");
} catch {
// missing counts as drift
}
if (current !== text) {
drifted = true;
console.error(`drift: ${shown} differs from a fresh generation`);
}
} else {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, text);
console.log(`wrote ${shown}`);
}
}
if (drifted) {
console.error("run `node tools/icons/generate.mjs` and commit the result");
process.exit(1);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}