diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..d3c188f --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -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: Β· repository: +- Package: `@fortawesome/free-solid-svg-icons` +- Licence: + - 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: Β· repository: +- 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. diff --git a/admin/package-lock.json b/admin/package-lock.json index 7da0990..adab7b6 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -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" diff --git a/admin/package.json b/admin/package.json index cb86399..6949dd5 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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" diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css index a857716..bd47f8a 100644 --- a/admin/src/definitions/DefinitionsTab.css +++ b/admin/src/definitions/DefinitionsTab.css @@ -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; +} diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 0db0d5d..7ee2a0d 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -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:, 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:`, `emoji:`, 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(); + 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", "", "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: 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: 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: 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", "", "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: 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: 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 { diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index dcbb698..4ac7748 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -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:`. */ + 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:` 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({ - {DEFINITION_ICONS.map(({ name, label, Icon }) => ( - - ))} + {DEFINITION_ICONS.map((icon) => { + const { name, label } = icon; + const on = normalizeDefinitionIconName(draft.icon) === name; + return ( + + ); + })} + + Font Awesome + +
+ {FA_DEFINITION_ICONS.map((icon) => { + const { name, label } = icon; + const on = faIconName(draft.icon) === name; + return ( + + ); + })} +
+ diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx index dbf0a3d..575dd9f 100644 --- a/admin/src/definitions/definitionIcons.tsx +++ b/admin/src/definitions/definitionIcons.tsx @@ -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:`, `fa:` or `emoji:` β€” + * and a legacy bare `` 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:` 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:` 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 ; - return