From 14fe9128482b911037eb34ff378051ea04eee91d Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 26 Sep 2026 07:50:32 -0400 Subject: [PATCH] Shared icon registry: curated Lucide + Font Awesome, namespaced resolver tools/icons/curated-icons.json is the one hand-edited list (the 36 curated Lucide icons plus 22 Font Awesome Free solid chore icons, name + label). tools/icons/generate.mjs reads it, takes path data offline from the pinned lucide-react and @fortawesome/free-solid-svg-icons packages, converts Lucide circles/rects/lines to paths, and writes the same plain-data module (viewBox + path strings, emoji text, resolveIcon parser with a fallback entry) into admin/src/icons and frontend/src/icons. No CDN, no runtime fetch. The admin Definitions tab now draws its Lucide set from the registry (lucide-react becomes a generator-only dev dependency); the panel quest log renders lucide:, fa: and emoji: icons, falling back to the star. Drift tests in Vitest and pytest fail if the source or either artefact is edited alone. Task: 03ef0bb3-8228-4be6-bb73-ee8bb72c2f0f Co-Authored-By: Claude Opus 5.5 --- admin/package-lock.json | 27 +- admin/package.json | 3 +- admin/src/definitions/DefinitionsTab.tsx | 6 +- admin/src/definitions/definitionIcons.tsx | 111 +- admin/src/icons/GlyphSvg.tsx | 29 + admin/src/icons/registry.generated.ts | 765 +++++ admin/src/icons/registry.test.ts | 122 + .../nestquest/www/nestquest-cards.js | 2493 +++++++++++------ frontend/src/cards/quest-log.ts | 10 +- frontend/src/icons/registry.generated.ts | 765 +++++ frontend/src/icons/render.ts | 48 + tests/test_icon_registry.py | 94 + tools/icons/curated-icons.json | 64 + tools/icons/generate.mjs | 240 ++ 14 files changed, 3802 insertions(+), 975 deletions(-) create mode 100644 admin/src/icons/GlyphSvg.tsx create mode 100644 admin/src/icons/registry.generated.ts create mode 100644 admin/src/icons/registry.test.ts create mode 100644 frontend/src/icons/registry.generated.ts create mode 100644 frontend/src/icons/render.ts create mode 100644 tests/test_icon_registry.py create mode 100644 tools/icons/curated-icons.json create mode 100644 tools/icons/generate.mjs 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.tsx b/admin/src/definitions/DefinitionsTab.tsx index bb5a7ea..92efd51 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -49,6 +49,7 @@ import { 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. */ @@ -581,7 +582,8 @@ function EditSheet({ > - {DEFINITION_ICONS.map(({ name, label, Icon }) => { + {DEFINITION_ICONS.map((icon) => { + const { name, label } = icon; const on = normalizeDefinitionIconName(draft.icon) === name; return ( ); })} diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx index 484a430..8343321 100644 --- a/admin/src/definitions/definitionIcons.tsx +++ b/admin/src/definitions/definitionIcons.tsx @@ -1,96 +1,18 @@ /** - * 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 namespaced `lucide:`; a legacy bare `` - * still names the same Lucide entry. + * The curated Lucide set a definition's `icon` may name (ADMIN-SPEC §3.3). + * 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 the namespaced `lucide:`; 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 { 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[] = [ - { 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 const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS; const LUCIDE_PREFIX = "lucide:"; @@ -110,14 +32,19 @@ export function normalizeDefinitionIconName(value: string | null): string | null return value.includes(":") ? null : value; } +/** + * The curated Lucide entry an icon value resolves to via the shared parser. + * The admin draws only Lucide glyphs for now; `fa:`, `emoji:`, unknown and + * null values give null. + */ export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null { - const name = normalizeDefinitionIconName(value); - return name === null ? null : (BY_NAME.get(name) ?? null); + const icon = resolveIcon(value); + return icon.kind === "lucide" ? icon : null; } -/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */ +/** A definition's glyph; no icon, or one outside the Lucide subset, gets the generic task glyph. */ export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) { const entry = findDefinitionIcon(name); if (!entry) return ; - return