Feature: expanded and custom task icons (Lucide + Font Awesome + emoji) (#248)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #248.
This commit is contained in:
2026-09-26 09:02:07 -04:00
committed by approver_agent
parent fb9907ae7f
commit bdf6d0aca1
33 changed files with 5108 additions and 1023 deletions
+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();
}