Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 5bd34f7822 Distinguish Font Awesome quick picks from full-set resolution in comments (task e2fc2dbc)
definitionIcons.tsx and frontend render.ts still described Font Awesome
as a curated subset; fa:<name> resolves against the full Free Solid set.
Test names updated to match. Comments and test titles only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:56:05 -04:00
dev_agentandClaude Opus 5.5 7a776bd5d7 Document the full-set searchable Font Awesome picker (task e2fc2dbc)
ADMIN-SPEC §3.3 now describes the Font Awesome source as a search over
the full Free Solid set plus a curated quick-pick grid, with the
free-text rule (exact Free Solid names accepted; partial, misspelled,
Pro or Brands names rejected in the picker). Correct "curated subset"
wording in PANEL-SPEC, DESIGN-HANDOFF, THIRD-PARTY-NOTICES and the
generator header, and note the bundle weight (panel ~1 MB raw /
~276 kB gzip). Docs and comments only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:35:46 -04:00
dev_agent 966beb4862 Searchable Font Awesome Free Solid picker (task 9ca0bc36) (#290)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 18:31:19 -04:00
dev_agent 40cb3f667a Panel renders any Free Solid Font Awesome icon (task 3f21165f) (#291)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-09-30 18:04:27 -04:00
13 changed files with 2997 additions and 1130 deletions
+5 -5
View File
@@ -1,8 +1,8 @@
# Third-party notices
NestQuest bundles glyph data from the icon sets below. The curated names are listed in
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` reads each glyph's SVG path
data from the locally installed packages and generates it into both bundles
NestQuest bundles glyph data from the icon sets below. The curated Lucide names and the
Font Awesome quick-pick names are listed in `tools/icons/curated-icons.json`;
`tools/icons/generate.mjs` reads each glyph's SVG path data from the locally installed packages and generates it into both bundles
(`admin/src/icons/registry.generated.ts` and `frontend/src/icons/registry.generated.ts`).
Nothing is loaded from a CDN or fetched at runtime. The package versions used are recorded
in the header of each generated file.
@@ -15,8 +15,8 @@ in the header of each generated file.
- Icons: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- Fonts: [SIL OFL 1.1](https://openfontlicense.org)
- Code: [MIT](https://opensource.org/licenses/MIT)
- Use in NestQuest: a curated subset of the Free **solid** icon set, embedded as SVG path
data. No fonts are bundled.
- Use in NestQuest: the full Free **solid** icon set, embedded as SVG path data (a curated
subset doubles as the admin picker's quick picks). No fonts are bundled.
Font Awesome Free by @fontawesome — https://fontawesome.com — icons licensed under CC BY 4.0.
+31
View File
@@ -614,3 +614,34 @@
.defs-emoji {
line-height: 1;
}
/* Font Awesome search: glyph + label tiles, inline validation, paging */
.defs-icon-grid--results {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
.defs-icon-result {
flex-direction: column;
gap: 4px;
padding: 6px 4px;
font-family: inherit;
}
.defs-icon-label {
max-width: 100%;
overflow: hidden;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.defs-hint--error {
color: var(--nq-a-danger-strong);
}
.defs-show-more {
align-self: flex-start;
display: flex;
gap: 8px;
}
+242 -1
View File
@@ -7,8 +7,10 @@ import {
FA_DEFINITION_ICONS,
emojiIconKey,
faIconName,
faTypedIconKey,
findDefinitionIcon,
normalizeDefinitionIconName,
searchFaIcons,
} from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
@@ -1353,7 +1355,7 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
}
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => {
it("ships the curated Font Awesome quick picks, each drawn as a filled glyph", () => {
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
for (const entry of FA_DEFINITION_ICONS) {
expect(entry.kind).toBe("fa");
@@ -1529,6 +1531,245 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
});
});
/* ── Font Awesome search ────────────────────────────────────────────── */
describe("DefinitionsTab Font Awesome search", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
const CURATED = new Set(FA_DEFINITION_ICONS.map((icon) => icon.name));
function searchInput(s: HTMLElement): HTMLInputElement {
return within(s).getByRole("searchbox", { name: "Search Font Awesome icons" }) as HTMLInputElement;
}
function results(s: HTMLElement): HTMLElement {
return within(s).getByRole("radiogroup", { name: "Font Awesome search results" });
}
function resultKeys(s: HTMLElement): string[] {
return within(results(s))
.getAllByRole("radio")
.map((option) => option.getAttribute("data-icon-option")!);
}
function rowFaIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
return svg!.getAttribute("fill") === "none" ? null : svg!.getAttribute("data-icon");
}
async function save(s: HTMLElement) {
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
}
function fillNewTask(s: HTMLElement) {
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Walk the dog" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
}
it("searches the full Free Solid set by name or label, exact and prefix matches first", () => {
expect(searchFaIcons("")).toEqual([]);
expect(searchFaIcons(" ")).toEqual([]);
const dog = searchFaIcons(" DOG ").map((icon) => icon.name);
expect(dog[0]).toBe("dog");
expect(dog).toEqual(expect.arrayContaining(["hotdog", "shield-dog"]));
expect(dog.some((name) => !CURATED.has(name))).toBe(true);
// A label match: "Shield Dog" contains "shield d" though no name does.
expect(searchFaIcons("shield d").map((icon) => icon.name)).toContain("shield-dog");
expect(searchFaIcons("dog-leashed")).toEqual([]);
expect(faTypedIconKey("dragon")).toBe("fa:dragon");
expect(faTypedIconKey(" Dragon ")).toBe("fa:dragon");
for (const bad of ["", "drag", "dgo", "dog-leashed", "github", "fa:dog"]) {
expect(faTypedIconKey(bad), bad).toBeNull();
}
});
it("shows the curated quick picks while the search is empty", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getAllByRole("radio")).toHaveLength(FA_DEFINITION_ICONS.length);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome search results" })).toBeNull();
});
it("a query swaps the quick picks for one bounded, labelled page of results", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "a" } });
const total = searchFaIcons("a").length;
expect(total).toBeGreaterThan(80);
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome" })).toBeNull();
expect(resultKeys(s)).toHaveLength(40);
expect(within(results(s)).getAllByRole("radio")[0].textContent).toBe(searchFaIcons("a")[0].label);
expect(within(s).getByText(`Showing 1–40 of ${total}`)).toBeTruthy();
expect(within(s).queryByRole("button", { name: "Previous" })).toBeNull();
// "Show more" replaces the page rather than appending to it.
const firstPage = resultKeys(s);
fireEvent.click(within(s).getByRole("button", { name: "Show more" }));
expect(resultKeys(s)).toEqual(searchFaIcons("a").slice(40, 80).map((icon) => `fa:${icon.name}`));
expect(resultKeys(s).some((key) => firstPage.includes(key))).toBe(false);
expect(within(s).getByText(`Showing 41–80 of ${total}`)).toBeTruthy();
fireEvent.click(within(s).getByRole("button", { name: "Previous" }));
expect(resultKeys(s)).toEqual(firstPage);
// A new query starts again from the first page.
fireEvent.change(searchInput(s), { target: { value: "dog" } });
expect(resultKeys(s)[0]).toBe("fa:dog");
expect(resultKeys(s)).toContain("fa:hotdog");
expect(within(s).queryByRole("button", { name: "Show more" })).toBeNull();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radiogroup", { name: "Font Awesome" })).toBeTruthy();
});
it("paging through every match never mounts more than one page", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "-" } });
const all = searchFaIcons("-").map((icon) => `fa:${icon.name}`);
expect(all.length).toBeGreaterThan(900);
const seen: string[] = [];
for (;;) {
expect(results(s).children.length).toBeGreaterThan(0);
expect(results(s).children.length).toBeLessThanOrEqual(40);
seen.push(...resultKeys(s));
const more = within(s).queryByRole("button", { name: "Show more" });
if (!more) break;
fireEvent.click(more);
}
// Every match stays reachable, one page at a time, in order.
expect(seen).toEqual(all);
const lastStart = Math.floor((all.length - 1) / 40) * 40 + 1;
expect(within(s).getByText(`Showing ${lastStart}–${all.length} of ${all.length}`)).toBeTruthy();
});
it("selecting a search result POSTs fa:<name> and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fillNewTask(s);
fireEvent.change(searchInput(s), { target: { value: "hot" } });
expect(within(s).getByRole("alert").textContent).toMatch(/Select a Font Awesome icon from the results/);
const hotdog = within(results(s)).getByRole("radio", { name: "Hotdog" });
fireEvent.click(hotdog);
expect(hotdog.getAttribute("aria-checked")).toBe("true");
expect(within(s).queryByRole("alert")).toBeNull();
expect(searchInput(s).value).toBe("hot");
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:hotdog");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-99"))).toBe("hotdog"));
});
it("a typed non-curated Free Solid name is accepted and PATCHed", async () => {
expect(CURATED.has("dragon")).toBe(false);
await renderReady([{ ...BRUSH, id: 70, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-70"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: "Dragon" } });
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
const lucide = within(s).getByRole("radiogroup", { name: "Icon" });
expect(within(lucide).queryAllByRole("radio", { checked: true })).toHaveLength(0);
await save(s);
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dragon");
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-70"))).toBe("dragon"));
});
for (const [typed, message] of [
["do", /Select a Font Awesome icon from the results/],
["dgo", /“dgo” is not a Font Awesome Free Solid icon/],
["dog-leashed", /“dog-leashed” is not a Font Awesome Free Solid icon/],
["github", /“github” is not a Font Awesome Free Solid icon/],
] as const) {
it(`typed ${JSON.stringify(typed)} is rejected with a message and nothing is saved`, async () => {
await renderReady([{ ...BRUSH, id: 71, icon: "lucide:dog" }]);
fireEvent.click(screen.getByTestId("definition-71"));
const s = sheet();
fireEvent.change(searchInput(s), { target: { value: typed } });
expect(within(s).getByRole("alert").textContent).toMatch(message);
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("false");
await save(s);
expect(api.writes()).toHaveLength(0);
expect(within(s).getByTestId("sheet-error").textContent).toMatch(message);
expect(screen.getByRole("dialog")).toBe(s);
});
}
it("clearing the search, or No icon, removes the icon", async () => {
await renderReady([
{ ...BRUSH, id: 72, icon: "fa:dragon" },
{ ...BRUSH, id: 73, title: "Task 73", icon: "fa:dragon" },
]);
fireEvent.click(screen.getByTestId("definition-72"));
let s = sheet();
fireEvent.change(searchInput(s), { target: { value: "" } });
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("true");
await save(s);
expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
fireEvent.click(screen.getByTestId("definition-73"));
s = sheet();
fireEvent.click(within(s).getByRole("radio", { name: "No icon" }));
expect(searchInput(s).value).toBe("");
await save(s);
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
});
it("a saved non-curated fa:<name> renders on its row and opens selected in the results", async () => {
await renderReady([{ ...BRUSH, id: 74, icon: "fa:dragon" }]);
expect(rowFaIcon(screen.getByTestId("definition-74"))).toBe("dragon");
fireEvent.click(screen.getByTestId("definition-74"));
const s = sheet();
expect(searchInput(s).value).toBe("dragon");
expect(within(s).queryByRole("alert")).toBeNull();
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
"true",
);
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the dragon" },
});
await save(s);
expect(api.writes()[0].body).not.toHaveProperty("icon");
});
it("a saved curated fa:<name> opens in the quick picks with an empty search", async () => {
await renderReady([{ ...BRUSH, id: 75, icon: "fa:dog" }]);
fireEvent.click(screen.getByTestId("definition-75"));
const s = sheet();
expect(searchInput(s).value).toBe("");
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
expect(within(quick).getByRole("radio", { name: "Dog" }).getAttribute("aria-checked")).toBe("true");
});
});
/* ── Legacy icons ───────────────────────────────────────────────────── */
describe("DefinitionsTab legacy icon edits", () => {
+116 -26
View File
@@ -4,6 +4,7 @@
*/
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
@@ -53,8 +54,12 @@ import {
emojiIconText,
faIconKey,
faIconName,
faTextProblem,
faTypedIconKey,
findDefinitionIcon,
lucideIconKey,
normalizeDefinitionIconName,
searchFaIcons,
} from "./definitionIcons";
import { GlyphSvg } from "../icons/GlyphSvg";
import { weekdayOf } from "../schedule/cycle";
@@ -87,6 +92,9 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
/** How many upcoming dates the edit sheet previews. */
const PREVIEW_COUNT = 5;
/** Font Awesome search results per page; only one page is ever mounted. */
const FA_RESULTS_PAGE = 40;
/** Quiet period after the last rule edit before the preview is requested. */
const PREVIEW_DEBOUNCE_MS = 300;
@@ -101,6 +109,11 @@ interface Draft {
icon: string | null;
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
emoji: string;
/**
* The Font Awesome search text; typing an exact Free Solid name sets
* `icon` to `fa:<name>`, and picking a search result keeps the text.
*/
faText: string;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -135,6 +148,7 @@ function newDraft(activeChildren: AdminChild[], today: string): Draft {
title: "",
icon: null,
emoji: "",
faText: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
@@ -157,10 +171,13 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], to
const active = new Set(activeChildren.map((child) => child.id));
const dueTimes = emptyDueTimes();
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
const fa = findDefinitionIcon(definition.icon);
return {
title: definition.title,
icon: definition.icon,
emoji: emojiIconText(definition.icon),
// A Free Solid icon outside the quick picks opens as its search result.
faText: fa?.kind === "fa" && !FA_DEFINITION_ICONS.includes(fa) ? fa.name : "",
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -250,6 +267,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
if (draft.emoji && draft.icon === null) {
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
}
if (draft.faText.trim() && draft.icon === null) return faTextProblem(draft.faText);
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
@@ -479,6 +497,11 @@ function EditSheet({
? []
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
const [pickerOpen, setPickerOpen] = useState(false);
const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]);
const [faPage, setFaPage] = useState(0);
const faStart = faPage * FA_RESULTS_PAGE;
const faEnd = Math.min(faStart + FA_RESULTS_PAGE, faResults.length);
const noIcon = draft.icon === null && !draft.emoji && !draft.faText.trim();
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
@@ -619,12 +642,10 @@ function EditSheet({
<button
type="button"
role="radio"
aria-checked={draft.icon === null && !draft.emoji}
aria-checked={noIcon}
aria-label="No icon"
className={
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
}
onClick={() => update({ icon: null, emoji: "" })}
className={noIcon ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null, emoji: "", faText: "" })}
>
<TaskGlyph size={18} />
</button>
@@ -640,7 +661,7 @@ function EditSheet({
aria-label={label}
data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
onClick={() => update({ icon: lucideIconKey(name), emoji: "", faText: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
@@ -650,26 +671,93 @@ function EditSheet({
<span className="defs-sublabel" id="defs-label-icon-fa">
Font Awesome
</span>
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
<input
className="defs-input"
type="search"
aria-label="Search Font Awesome icons"
placeholder="Search or type an icon name, e.g. dog"
value={draft.faText}
onChange={(e) => {
setFaPage(0);
update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" });
}}
/>
{draft.faText.trim() && draft.icon === null ? (
<p className="defs-hint defs-hint--error" role="alert">
{faTextProblem(draft.faText)}
</p>
) : null}
{draft.faText.trim() ? (
faResults.length > 0 ? (
<>
<div
className="defs-icon-grid defs-icon-grid--results"
role="radiogroup"
aria-label="Font Awesome search results"
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
{faResults.slice(faStart, faEnd).map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={
on ? "defs-icon defs-icon-result defs-icon--on" : "defs-icon defs-icon-result"
}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
<span className="defs-icon-label">{label}</span>
</button>
);
})}
</div>
<p className="defs-hint">
Showing {faStart + 1}–{faEnd} of {faResults.length}
</p>
{faResults.length > FA_RESULTS_PAGE ? (
<div className="defs-show-more">
{faPage > 0 ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page - 1)}>
Previous
</button>
) : null}
{faEnd < faResults.length ? (
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page + 1)}>
Show more
</button>
) : null}
</div>
) : null}
</>
) : null
) : (
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "", faText: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
)}
<label className="defs-due">
<span>Emoji</span>
<input
@@ -677,7 +765,9 @@ function EditSheet({
aria-label="Emoji"
placeholder="e.g. 🐶"
value={draft.emoji}
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
onChange={(e) =>
update({ emoji: e.target.value, icon: emojiIconKey(e.target.value), faText: "" })
}
/>
</label>
</Field>
+54 -9
View File
@@ -1,14 +1,23 @@
/**
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
* names, labels and path data come from the shared icon registry
* (`src/icons/registry.generated.ts`, generated from
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
* Lucide set, any icon in the full Font Awesome Free Solid set, or an emoji.
* The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
* shortcut list; `fa:<name>` resolves against the full set
* (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
* icon registry (`src/icons/registry.generated.ts`, generated by
* `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
* installed Free Solid package) — no CDN, no runtime fetch. The stored value
* is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` — and a
* legacy bare `<kebab-name>` still names the same Lucide entry.
*/
import { GlyphSvg } from "../icons/GlyphSvg";
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import {
FA_ICONS,
FA_SOLID_ICONS,
LUCIDE_ICONS,
resolveIcon,
type GlyphIcon,
} from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs";
export type DefinitionIconEntry = GlyphIcon;
@@ -58,14 +67,50 @@ export function faIconName(value: string | null): string | null {
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
}
/**
* The Font Awesome Free Solid icons whose name or label contains `query`
* (trimmed, case-insensitive): an exact name first, then name-prefix
* matches, then the rest, each in registry (name) order. An empty query
* gives no results — the curated quick picks cover that case.
*/
export function searchFaIcons(query: string): DefinitionIconEntry[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
return FA_SOLID_ICONS.filter(
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
).sort((a, b) => rank(a) - rank(b));
}
/**
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
* else null (a misspelling, a partial search, or a Pro/Brands name).
*/
export function faTypedIconKey(text: string): string | null {
const key = faIconKey(text.trim().toLowerCase());
return resolveIcon(key).kind === "fa" ? key : null;
}
/**
* Why Font Awesome search text cannot be saved as it stands: it matches
* icons but names none of them, or it matches nothing in the Free Solid set.
*/
export function faTextProblem(text: string): string {
return searchFaIcons(text).length > 0
? "Select a Font Awesome icon from the results, or type its full name."
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
}
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
export function emojiIconText(value: string | null): string {
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
}
/**
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
* the shared parser; emoji, unknown and null values give null.
* The glyph an icon value resolves to via the shared parser: a curated
* Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
* emoji, unknown and null values give null.
*/
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
const icon = resolveIcon(value);
+1 -1
View File
@@ -157,7 +157,7 @@ describe("offline bundling", () => {
}
});
it("no bundled source imports an icon package — only the curated generated data ships", () => {
it("no bundled source imports an icon package — only the generated registry data ships", () => {
const sources = [join(REPO_ROOT, "admin", "src"), join(REPO_ROOT, "frontend", "src")]
.flatMap(filesUnder)
.filter((path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"));
File diff suppressed because it is too large Load Diff
+16 -5
View File
@@ -105,14 +105,25 @@ Each definition carries one optional icon, shown on both surfaces. The picker of
sources:
- **Lucide** — a grid of the curated Lucide glyphs, drawn stroked.
- **Font Awesome** — a grid of a curated subset of the Font Awesome Free *solid* set,
drawn filled.
- **Font Awesome** — a search over the **full** Font Awesome Free *solid* set (every
glyph in `@fortawesome/free-solid-svg-icons`), plus a curated quick-pick grid, all drawn
filled. With the search field empty the quick-pick grid shows; typing filters the full
set by name or label (an exact name ranks first, then name prefixes, then the rest),
shown 40 results per page with *Previous* / *Show more*.
**Free-text rule:** an admin may type a Free Solid icon name directly (trimmed,
case-insensitive — `Dog` stores `fa:dog`). Text that is not the exact name of a Free
Solid icon — a partial search, a misspelling, or a Pro or Brands name — is rejected in
the picker with an inline error and cannot be saved until a result is selected or the
full name is typed.
- **Emoji** — a text input; the typed emoji is stored as entered (not trimmed) and must be
1–8 code points with no whitespace, control characters, `<`, `>` or `&`.
Both curated lists come from `tools/icons/curated-icons.json`; their glyph path data is
generated into the admin and panel bundles by `tools/icons/generate.mjs` (no CDN, no
runtime fetch). See `THIRD-PARTY-NOTICES.md` for licences.
The curated Lucide set and the Font Awesome quick-pick list come from
`tools/icons/curated-icons.json`; `tools/icons/generate.mjs` generates their glyph path
data, together with the full Font Awesome Free Solid set, into the admin and panel bundles
(no CDN, no runtime fetch). Embedding the full set makes both bundles heavier — the panel
bundle (`nestquest-cards.js`) is about 1 MB raw / 276 kB gzip. See
`THIRD-PARTY-NOTICES.md` for licences.
The stored value is a **namespaced key**:
+2 -2
View File
@@ -78,6 +78,6 @@ The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest
decide before shipping the card.
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
but the append-only event log already supports computing them later.
- Icon per quest definition is chosen by the parent from curated Lucide and Font Awesome
Free sets or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
- Icon per quest definition is chosen by the parent from the curated Lucide set, the full
Font Awesome Free Solid set (searchable, with curated quick picks) or as an emoji, stored as a namespaced key (`lucide:`/`fa:`/`emoji:`) —
resolved 2026-09-26, see `ADMIN-SPEC.md` §3.3.
+2 -1
View File
@@ -106,7 +106,8 @@ collapse the column, the three-column rhythm is the layout.
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, glyph 34px white — the
definition's icon, resolved from its namespaced key (`ADMIN-SPEC.md` §3.3):
`lucide:<name>` draws the curated Lucide glyph **stroked** (fill none, stroke-width 2,
round caps and joins); `fa:<name>` draws the curated Font Awesome glyph **filled**;
round caps and joins); `fa:<name>` draws the Font Awesome Free Solid glyph **filled** (any icon in the full
set, not only the quick picks);
`emoji:<grapheme>` renders the emoji **as text**. A legacy bare name resolves as
`lucide:<name>`. No icon, or any key that does not resolve (unknown namespace or name,
malformed emoji), falls back to the **star** glyph. Glyph data is bundled from the
+3 -2
View File
@@ -1,7 +1,8 @@
/**
* Lit rendering for the shared icon registry (`registry.generated.ts`,
* generated from tools/icons/curated-icons.json). The glyph path data is
* bundled — no CDN and no runtime fetch.
* generated by tools/icons/generate.mjs: the curated Lucide set plus the full
* Font Awesome Free Solid set). The glyph path data is bundled — no CDN and
* no runtime fetch.
*/
import { html, svg, type TemplateResult } from "lit";
+20
View File
@@ -1431,6 +1431,26 @@ def test_open_quest_tile_falls_back_to_the_star() -> None:
assert tile["icon"] is None
def test_open_quest_tile_renders_any_free_solid_icon() -> None:
"""A Font Awesome Free Solid name outside the curated quick-pick list
still renders its glyph from the shipped bundle; an unknown ``fa:`` name
keeps the star."""
tiles = _quest_log_tiles(
[
_quest_row(1, "fa:rocket"),
_quest_row(2, "fa:mug-hot"),
_quest_row(3, "fa:definitely-not-an-icon"),
]
)
for instance_id, key in ((1, "fa:rocket"), (2, "fa:mug-hot")):
assert tiles[instance_id]["icon"] == key
assert tiles[instance_id]["fill"] == "currentColor"
assert tiles[instance_id]["paths"] > 0
assert not tiles[instance_id]["star"]
assert tiles[3]["star"]
assert tiles[3]["icon"] is None
def test_sealed_quest_tile_keeps_the_star() -> None:
"""A sealed card keeps its star tile (and its wax seal) whatever the
definition's icon; only the open card shows the quest's icon."""
+5 -3
View File
@@ -16,13 +16,15 @@
* Lucide circles, rects and lines are converted to path `d` strings so every
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
*
* Besides the curated sets, the module embeds the complete Font Awesome Free
* Solid set (`FA_SOLID_ICONS`). Its source is the installed
* Besides the curated sets (the Lucide set and the Font Awesome quick picks),
* the module embeds the complete Font Awesome Free Solid set (`FA_SOLID_ICONS`),
* which is what `fa:<name>` resolves against. Its source is the installed
* @fortawesome/free-solid-svg-icons package itself: every exported icon
* definition, de-duplicated by canonical `iconName` (the package also exports
* each legacy alias, e.g. faHome for `house`, as the same definition). The
* package carries no human labels, so each label is the kebab name in Title
* Case (`spray-can-sparkles` → "Spray Can Sparkles").
* Case (`spray-can-sparkles` → "Spray Can Sparkles"). Embedding the full set
* makes both bundles heavier: the panel bundle is about 1 MB raw / 276 kB gzip.
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";