Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 e810243dd2 Admin: omit an unchanged icon from the definition PATCH (NQ-ICON-02)
A definition stored with a legacy icon (e.g. mdi:tooth) reads back as
lucide:mdi:tooth, which the API's strict validate_icon rejects, so any
edit that echoed the icon back failed. Mirror the keepsAssignees
pattern: keep the loaded icon and delete `icon` from the PATCH body
while the draft icon is unchanged. The edit route dumps the body with
exclude_unset, so an absent icon leaves the stored value untouched;
a new choice is sent and No icon still sends null. Create always
sends icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:54:54 -04:00
dev_agent de76b6e11a Feature review remediation: emoji whitespace-contract parity (U+FEFF) (#249) 2026-09-26 08:49:51 -04:00
dev_agent 7eed8fdbf1 Docs: namespaced icon keys, picker, and third-party icon attribution (#246) 2026-09-26 08:31:22 -04:00
dev_agent 3bd30a0b9f Kids' panel: render the definition icon on the open quest card (#245) 2026-09-26 08:23:09 -04:00
dev_agent a93f91d4af Admin PWA: Lucide + Font Awesome + emoji icon picker (#243) 2026-09-26 08:15:13 -04:00
dev_agent a12451b58c Shared icon registry (curated Lucide + Font Awesome, namespaced resolver) (#242) 2026-09-26 07:55:46 -04:00
17 changed files with 904 additions and 165 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.
+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;
}
+351 -9
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, normalizeDefinitionIconName } 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,
@@ -1079,7 +1090,7 @@ describe("DefinitionsTab icon picker", () => {
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,7 +1124,7 @@ 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();
});
@@ -1166,7 +1177,7 @@ describe("DefinitionsTab namespaced icons", () => {
});
}
for (const icon of ["fa:dog", "emoji:🐶", "mystery:dog"]) {
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");
@@ -1174,9 +1185,11 @@ describe("DefinitionsTab namespaced icons", () => {
fireEvent.click(row);
const s = sheet();
const group = within(s).getByRole("radiogroup", { name: "Icon" });
for (const radio of within(group).getAllByRole("radio")) {
expect(radio.getAttribute("aria-checked")).toBe("false");
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");
}
}
});
}
@@ -1207,6 +1220,335 @@ describe("DefinitionsTab namespaced icons", () => {
});
});
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 {
+60 -5
View File
@@ -46,6 +46,11 @@ import {
import {
DEFINITION_ICONS,
DefinitionIcon,
FA_DEFINITION_ICONS,
emojiIconKey,
emojiIconText,
faIconKey,
faIconName,
lucideIconKey,
normalizeDefinitionIconName,
} from "./definitionIcons";
@@ -84,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;
@@ -126,6 +136,7 @@ function newDraft(activeChildren: AdminChild[]): Draft {
return {
title: "",
icon: null,
emoji: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
@@ -151,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],
@@ -237,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(
@@ -447,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"
? []
@@ -529,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();
@@ -575,10 +595,12 @@ 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>
@@ -594,13 +616,46 @@ function EditSheet({
aria-label={label}
data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name) })}
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">
+53 -13
View File
@@ -1,26 +1,47 @@
/**
* 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
* 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 the namespaced `lucide:<kebab-name>`; a legacy bare
* `<kebab-name>` still names the same Lucide entry.
* 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 { LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
import { TaskGlyph } from "./glyphs";
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}`;
}
/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */
export function faIconKey(name: string): string {
return `${FA_PREFIX}${name}`;
}
/**
* 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
@@ -32,19 +53,38 @@ export function normalizeDefinitionIconName(value: string | null): string | null
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 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.
* 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 : null;
return icon.kind === "lucide" || icon.kind === "fa" ? icon : null;
}
/** A definition's glyph; no icon, or one outside the Lucide subset, gets the generic task glyph. */
/**
* 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 <GlyphSvg icon={entry} size={size} />;
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} />;
}
+25 -1
View File
@@ -3,12 +3,18 @@ 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 { FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
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();
@@ -86,6 +92,24 @@ describe("resolveIcon", () => {
}
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[] {
+9 -3
View File
@@ -6,8 +6,9 @@ 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, no
control characters and none of ``<``, ``>``, ``&``.
: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
@@ -33,7 +34,12 @@ ICON_NAMESPACES = frozenset(
EMOJI_MAX_CODE_POINTS = 8
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
_EMOJI_FORBIDDEN = frozenset("<>&")
# 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:
+122 -122
View File
@@ -1,5 +1,5 @@
const F = globalThis, se = F.ShadowRoot && (F.ShadyCSS === void 0 || F.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, ie = /* @__PURE__ */ Symbol(), ce = /* @__PURE__ */ new WeakMap();
let Ie = class {
let Pe = class {
constructor(e, t, n) {
if (this._$cssResult$ = !0, n !== ie) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
this.cssText = e, this.t = t;
@@ -17,14 +17,14 @@ let Ie = class {
return this.cssText;
}
};
const v = (s) => new Ie(typeof s == "string" ? s : s + "", void 0, ie), He = (s, ...e) => {
const v = (s) => new Pe(typeof s == "string" ? s : s + "", void 0, ie), Ie = (s, ...e) => {
const t = s.length === 1 ? s[0] : e.reduce((n, i, a) => n + ((r) => {
if (r._$cssResult$ === !0) return r.cssText;
if (typeof r == "number") return r;
throw Error("Value passed to 'css' function must be a 'css' function result: " + r + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.");
})(i) + s[a + 1], s[0]);
return new Ie(t, s, ie);
}, Ge = (s, e) => {
return new Pe(t, s, ie);
}, We = (s, e) => {
if (se) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet);
else for (const t of e) {
const n = document.createElement("style"), i = F.litNonce;
@@ -35,10 +35,10 @@ const v = (s) => new Ie(typeof s == "string" ? s : s + "", void 0, ie), He = (s,
for (const n of e.cssRules) t += n.cssText;
return v(t);
})(s) : s;
const { is: Ke, defineProperty: Je, getOwnPropertyDescriptor: Xe, getOwnPropertyNames: Ye, getOwnPropertySymbols: et, getPrototypeOf: tt } = Object, Z = globalThis, pe = Z.trustedTypes, nt = pe ? pe.emptyScript : "", st = Z.reactiveElementPolyfillSupport, P = (s, e) => s, ne = { toAttribute(s, e) {
const { is: Ge, defineProperty: Ke, getOwnPropertyDescriptor: Je, getOwnPropertyNames: Xe, getOwnPropertySymbols: Ye, getPrototypeOf: et } = Object, Z = globalThis, pe = Z.trustedTypes, tt = pe ? pe.emptyScript : "", nt = Z.reactiveElementPolyfillSupport, P = (s, e) => s, ne = { toAttribute(s, e) {
switch (e) {
case Boolean:
s = s ? nt : null;
s = s ? tt : null;
break;
case Object:
case Array:
@@ -63,7 +63,7 @@ const { is: Ke, defineProperty: Je, getOwnPropertyDescriptor: Xe, getOwnProperty
}
}
return t;
} }, je = (s, e) => !Ke(s, e), he = { attribute: !0, type: String, converter: ne, reflect: !1, useDefault: !1, hasChanged: je };
} }, He = (s, e) => !Ge(s, e), he = { attribute: !0, type: String, converter: ne, reflect: !1, useDefault: !1, hasChanged: He };
Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), Z.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
let T = class extends HTMLElement {
static addInitializer(e) {
@@ -75,11 +75,11 @@ let T = class extends HTMLElement {
static createProperty(e, t = he) {
if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) {
const n = /* @__PURE__ */ Symbol(), i = this.getPropertyDescriptor(e, n, t);
i !== void 0 && Je(this.prototype, e, i);
i !== void 0 && Ke(this.prototype, e, i);
}
}
static getPropertyDescriptor(e, t, n) {
const { get: i, set: a } = Xe(this.prototype, e) ?? { get() {
const { get: i, set: a } = Je(this.prototype, e) ?? { get() {
return this[t];
}, set(r) {
this[t] = r;
@@ -94,13 +94,13 @@ let T = class extends HTMLElement {
}
static _$Ei() {
if (this.hasOwnProperty(P("elementProperties"))) return;
const e = tt(this);
const e = et(this);
e.finalize(), e.l !== void 0 && (this.l = [...e.l]), this.elementProperties = new Map(e.elementProperties);
}
static finalize() {
if (this.hasOwnProperty(P("finalized"))) return;
if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(P("properties"))) {
const t = this.properties, n = [...Ye(t), ...et(t)];
const t = this.properties, n = [...Xe(t), ...Ye(t)];
for (const i of n) this.createProperty(i, t[i]);
}
const e = this[Symbol.metadata];
@@ -146,7 +146,7 @@ let T = class extends HTMLElement {
}
createRenderRoot() {
const e = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);
return Ge(e, this.constructor.elementStyles), e;
return We(e, this.constructor.elementStyles), e;
}
connectedCallback() {
this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((e) => e.hostConnected?.());
@@ -178,7 +178,7 @@ let T = class extends HTMLElement {
requestUpdate(e, t, n, i = !1, a) {
if (e !== void 0) {
const r = this.constructor;
if (i === !1 && (a = this[e]), n ??= r.getPropertyOptions(e), !((n.hasChanged ?? je)(a, t) || n.useDefault && n.reflect && a === this._$Ej?.get(e) && !this.hasAttribute(r._$Eu(e, n)))) return;
if (i === !1 && (a = this[e]), n ??= r.getPropertyOptions(e), !((n.hasChanged ?? He)(a, t) || n.useDefault && n.reflect && a === this._$Ej?.get(e) && !this.hasAttribute(r._$Eu(e, n)))) return;
this.C(e, t, n);
}
this.isUpdatePending === !1 && (this._$ES = this._$EP());
@@ -246,43 +246,43 @@ let T = class extends HTMLElement {
firstUpdated(e) {
}
};
T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[P("elementProperties")] = /* @__PURE__ */ new Map(), T[P("finalized")] = /* @__PURE__ */ new Map(), st?.({ ReactiveElement: T }), (Z.reactiveElementVersions ??= []).push("2.1.2");
const ae = globalThis, ue = (s) => s, Q = ae.trustedTypes, fe = Q ? Q.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, Re = "$lit$", k = `lit$${Math.random().toFixed(9).slice(2)}$`, Ue = "?" + k, it = `<${Ue}>`, A = document, I = () => A.createComment(""), H = (s) => s === null || typeof s != "object" && typeof s != "function", re = Array.isArray, at = (s) => re(s) || typeof s?.[Symbol.iterator] == "function", K = `[
T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[P("elementProperties")] = /* @__PURE__ */ new Map(), T[P("finalized")] = /* @__PURE__ */ new Map(), nt?.({ ReactiveElement: T }), (Z.reactiveElementVersions ??= []).push("2.1.2");
const ae = globalThis, ue = (s) => s, Q = ae.trustedTypes, fe = Q ? Q.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, je = "$lit$", k = `lit$${Math.random().toFixed(9).slice(2)}$`, Re = "?" + k, st = `<${Re}>`, A = document, I = () => A.createComment(""), H = (s) => s === null || typeof s != "object" && typeof s != "function", re = Array.isArray, it = (s) => re(s) || typeof s?.[Symbol.iterator] == "function", K = `[
\f\r]`, L = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, me = /-->/g, ge = />/g, $ = RegExp(`>|${K}(?:([^\\s"'>=/]+)(${K}*=${K}*(?:[^
\f\r"'\`<>=]|("|')|))|$)`, "g"), _e = /'/g, ye = /"/g, De = /^(?:script|style|textarea|title)$/i, Fe = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), d = Fe(1), be = Fe(2), S = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), ve = /* @__PURE__ */ new WeakMap(), C = A.createTreeWalker(A, 129);
function Qe(s, e) {
\f\r"'\`<>=]|("|')|))|$)`, "g"), _e = /'/g, ye = /"/g, Ue = /^(?:script|style|textarea|title)$/i, De = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), d = De(1), be = De(2), S = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), ve = /* @__PURE__ */ new WeakMap(), C = A.createTreeWalker(A, 129);
function Fe(s, e) {
if (!re(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array");
return fe !== void 0 ? fe.createHTML(e) : e;
}
const rt = (s, e) => {
const at = (s, e) => {
const t = s.length - 1, n = [];
let i, a = e === 2 ? "<svg>" : e === 3 ? "<math>" : "", r = L;
for (let l = 0; l < t; l++) {
const o = s[l];
let p, u, c = -1, f = 0;
for (; f < o.length && (r.lastIndex = f, u = r.exec(o), u !== null); ) f = r.lastIndex, r === L ? u[1] === "!--" ? r = me : u[1] !== void 0 ? r = ge : u[2] !== void 0 ? (De.test(u[2]) && (i = RegExp("</" + u[2], "g")), r = $) : u[3] !== void 0 && (r = $) : r === $ ? u[0] === ">" ? (r = i ?? L, c = -1) : u[1] === void 0 ? c = -2 : (c = r.lastIndex - u[2].length, p = u[1], r = u[3] === void 0 ? $ : u[3] === '"' ? ye : _e) : r === ye || r === _e ? r = $ : r === me || r === ge ? r = L : (r = $, i = void 0);
for (; f < o.length && (r.lastIndex = f, u = r.exec(o), u !== null); ) f = r.lastIndex, r === L ? u[1] === "!--" ? r = me : u[1] !== void 0 ? r = ge : u[2] !== void 0 ? (Ue.test(u[2]) && (i = RegExp("</" + u[2], "g")), r = $) : u[3] !== void 0 && (r = $) : r === $ ? u[0] === ">" ? (r = i ?? L, c = -1) : u[1] === void 0 ? c = -2 : (c = r.lastIndex - u[2].length, p = u[1], r = u[3] === void 0 ? $ : u[3] === '"' ? ye : _e) : r === ye || r === _e ? r = $ : r === me || r === ge ? r = L : (r = $, i = void 0);
const h = r === $ && s[l + 1].startsWith("/>") ? " " : "";
a += r === L ? o + it : c >= 0 ? (n.push(p), o.slice(0, c) + Re + o.slice(c) + k + h) : o + k + (c === -2 ? l : h);
a += r === L ? o + st : c >= 0 ? (n.push(p), o.slice(0, c) + je + o.slice(c) + k + h) : o + k + (c === -2 ? l : h);
}
return [Qe(s, a + (s[t] || "<?>") + (e === 2 ? "</svg>" : e === 3 ? "</math>" : "")), n];
return [Fe(s, a + (s[t] || "<?>") + (e === 2 ? "</svg>" : e === 3 ? "</math>" : "")), n];
};
class j {
constructor({ strings: e, _$litType$: t }, n) {
let i;
this.parts = [];
let a = 0, r = 0;
const l = e.length - 1, o = this.parts, [p, u] = rt(e, t);
const l = e.length - 1, o = this.parts, [p, u] = at(e, t);
if (this.el = j.createElement(p, n), C.currentNode = this.el.content, t === 2 || t === 3) {
const c = this.el.content.firstChild;
c.replaceWith(...c.childNodes);
}
for (; (i = C.nextNode()) !== null && o.length < l; ) {
if (i.nodeType === 1) {
if (i.hasAttributes()) for (const c of i.getAttributeNames()) if (c.endsWith(Re)) {
if (i.hasAttributes()) for (const c of i.getAttributeNames()) if (c.endsWith(je)) {
const f = u[r++], h = i.getAttribute(c).split(k), m = /([.?@])?(.*)/.exec(f);
o.push({ type: 1, index: a, name: m[2], strings: h, ctor: m[1] === "." ? lt : m[1] === "?" ? ct : m[1] === "@" ? dt : W }), i.removeAttribute(c);
o.push({ type: 1, index: a, name: m[2], strings: h, ctor: m[1] === "." ? ot : m[1] === "?" ? lt : m[1] === "@" ? ct : W }), i.removeAttribute(c);
} else c.startsWith(k) && (o.push({ type: 6, index: a }), i.removeAttribute(c));
if (De.test(i.tagName)) {
if (Ue.test(i.tagName)) {
const c = i.textContent.split(k), f = c.length - 1;
if (f > 0) {
i.textContent = Q ? Q.emptyScript : "";
@@ -290,7 +290,7 @@ class j {
i.append(c[f], I());
}
}
} else if (i.nodeType === 8) if (i.data === Ue) o.push({ type: 2, index: a });
} else if (i.nodeType === 8) if (i.data === Re) o.push({ type: 2, index: a });
else {
let c = -1;
for (; (c = i.data.indexOf(k, c + 1)) !== -1; ) o.push({ type: 7, index: a }), c += k.length - 1;
@@ -309,7 +309,7 @@ function z(s, e, t = s, n) {
const a = H(e) ? void 0 : e._$litDirective$;
return i?.constructor !== a && (i?._$AO?.(!1), a === void 0 ? i = void 0 : (i = new a(s), i._$AT(s, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = i : t._$Cl = i), i !== void 0 && (e = z(s, i._$AS(s, e.values), i, n)), e;
}
class ot {
class rt {
constructor(e, t) {
this._$AV = [], this._$AN = void 0, this._$AD = e, this._$AM = t;
}
@@ -326,7 +326,7 @@ class ot {
for (; o !== void 0; ) {
if (r === o.index) {
let p;
o.type === 2 ? p = new N(a, a.nextSibling, this, e) : o.type === 1 ? p = new o.ctor(a, o.name, o.strings, this, e) : o.type === 6 && (p = new pt(a, this, e)), this._$AV.push(p), o = n[++l];
o.type === 2 ? p = new N(a, a.nextSibling, this, e) : o.type === 1 ? p = new o.ctor(a, o.name, o.strings, this, e) : o.type === 6 && (p = new dt(a, this, e)), this._$AV.push(p), o = n[++l];
}
r !== o?.index && (a = C.nextNode(), r++);
}
@@ -356,7 +356,7 @@ class N {
return this._$AB;
}
_$AI(e, t = this) {
e = z(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : at(e) ? this.k(e) : this._(e);
e = z(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : it(e) ? this.k(e) : this._(e);
}
O(e) {
return this._$AA.parentNode.insertBefore(e, this._$AB);
@@ -368,10 +368,10 @@ class N {
this._$AH !== g && H(this._$AH) ? this._$AA.nextSibling.data = e : this.T(A.createTextNode(e)), this._$AH = e;
}
$(e) {
const { values: t, _$litType$: n } = e, i = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = j.createElement(Qe(n.h, n.h[0]), this.options)), n);
const { values: t, _$litType$: n } = e, i = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = j.createElement(Fe(n.h, n.h[0]), this.options)), n);
if (this._$AH?._$AD === i) this._$AH.p(t);
else {
const a = new ot(i, this), r = a.u(this.options);
const a = new rt(i, this), r = a.u(this.options);
a.p(t), this.T(r), this._$AH = a;
}
}
@@ -421,7 +421,7 @@ class W {
e === g ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? "");
}
}
class lt extends W {
class ot extends W {
constructor() {
super(...arguments), this.type = 3;
}
@@ -429,7 +429,7 @@ class lt extends W {
this.element[this.name] = e === g ? void 0 : e;
}
}
class ct extends W {
class lt extends W {
constructor() {
super(...arguments), this.type = 4;
}
@@ -437,7 +437,7 @@ class ct extends W {
this.element.toggleAttribute(this.name, !!e && e !== g);
}
}
class dt extends W {
class ct extends W {
constructor(e, t, n, i, a) {
super(e, t, n, i, a), this.type = 5;
}
@@ -450,7 +450,7 @@ class dt extends W {
typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, e) : this._$AH.handleEvent(e);
}
}
class pt {
class dt {
constructor(e, t, n) {
this.element = e, this.type = 6, this._$AN = void 0, this._$AM = t, this.options = n;
}
@@ -461,9 +461,9 @@ class pt {
z(this, e);
}
}
const ht = { I: N }, ut = ae.litHtmlPolyfillSupport;
ut?.(j, N), (ae.litHtmlVersions ??= []).push("3.3.3");
const ft = (s, e, t) => {
const pt = { I: N }, ht = ae.litHtmlPolyfillSupport;
ht?.(j, N), (ae.litHtmlVersions ??= []).push("3.3.3");
const ut = (s, e, t) => {
const n = t?.renderBefore ?? e;
let i = n._$litPart$;
if (i === void 0) {
@@ -483,7 +483,7 @@ let M = class extends T {
}
update(e) {
const t = this.render();
this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = ft(t, this.renderRoot, this.renderOptions);
this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = ut(t, this.renderRoot, this.renderOptions);
}
connectedCallback() {
super.connectedCallback(), this._$Do?.setConnected(!0);
@@ -496,14 +496,14 @@ let M = class extends T {
}
};
M._$litElement$ = !0, M.finalized = !0, oe.litElementHydrateSupport?.({ LitElement: M });
const mt = oe.litElementPolyfillSupport;
mt?.({ LitElement: M });
const ft = oe.litElementPolyfillSupport;
ft?.({ LitElement: M });
(oe.litElementVersions ??= []).push("4.2.2");
const Ve = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-quest-pad: 22px;--nq-p-tile-size: 64px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}';
const Qe = ':host{--nq-p-parchment-top: #f0e4c7;--nq-p-parchment-bottom: #ddcca3;--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%), radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%), repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px), repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px), linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9);--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2);--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca);--nq-p-card-border: rgba(120,88,48,.38);--nq-p-panel-border: rgba(92,62,26,.4);--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.2), inset 0 1px 0 rgba(255,255,255,.7);--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.7);--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);--nq-p-rule: 2px solid rgba(92,62,26,.35);--nq-p-ink: #2b1f14;--nq-p-ink-secondary: #5c452a;--nq-p-ink-muted: #6f6455;--nq-p-ink-away: #4d4433;--nq-p-ink-late: #8f1526;--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-p-icon-tile: var(--nq-brand-gradient);--nq-p-icon-tile-done: rgba(92,62,26,.16);--nq-p-crest: var(--nq-brand-gradient);--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.4), inset 0 0 0 4px rgba(255,255,255,.14);--nq-p-seal-size: 78px;--nq-p-dock-bg: rgba(43,31,20,.9);--nq-p-dock-ink: #f7efdb;--nq-p-dock-ink-secondary: #d8c9a6;--nq-p-dock-divider: rgba(233,220,189,.3);--nq-p-dock-height: 84px;--nq-p-font-display: "Cinzel Decorative", Cinzel, serif;--nq-p-font-heading: Cinzel, serif;--nq-p-font-body: Nunito, system-ui, sans-serif;--nq-p-size-hero: 86px;--nq-p-size-wordmark: 66px;--nq-p-size-title: 56px;--nq-p-size-name: 48px;--nq-p-size-section: 32px;--nq-p-size-quest: 30px;--nq-p-size-body: 22px;--nq-p-size-label: 21px;--nq-p-track-label: .14em;--nq-p-track-kicker: .3em;--nq-p-quest-min-height: 116px;--nq-p-quest-gap: 24px;--nq-p-quest-pad: 22px;--nq-p-tile-size: 64px;--nq-p-button-height: 72px;--nq-p-button-min-width: 150px;--nq-p-confirm-button: 108px;--nq-p-radius-card: 12px;--nq-p-radius-panel: 20px;--nq-p-radius-pill: 9999px;--nq-p-page-inset: 62px;--nq-p-frame-inset: 26px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}';
function le(s) {
window.customCards = window.customCards ?? [], window.customCards.push(s);
}
const gt = [
const mt = [
"nestquest_quest_completed",
"nestquest_quest_uncompleted",
"nestquest_quest_missed",
@@ -524,7 +524,7 @@ function V(s, e) {
}
return xe.set(t, i), i;
}
function _t(s) {
function gt(s) {
const e = s?.config;
if (!e || typeof e != "object")
return;
@@ -534,14 +534,14 @@ function _t(s) {
function J(s, e) {
return `${V(e, { weekday: "long" }).format(s)}, ${V(e, { month: "short", day: "numeric" }).format(s)}`;
}
function yt(s, e) {
function _t(s, e) {
return V(e, {
hour: "numeric",
minute: "2-digit",
hour12: !0
}).format(s);
}
const bt = {
const yt = {
"clear-night": "Clear",
cloudy: "Cloudy",
exceptional: "Clear",
@@ -557,7 +557,7 @@ const bt = {
sunny: "Sunny",
windy: "Windy",
"windy-variant": "Windy"
}, vt = {
}, bt = {
"clear-night": "Clear night skies",
cloudy: "Grey skies today",
exceptional: "A striking day",
@@ -583,7 +583,7 @@ function X(s, e) {
const t = typeof s == "number" ? s : Number(s);
return Number.isFinite(t) ? t : e;
}
function xt(s) {
function vt(s) {
if (s === null || typeof s != "object" || Array.isArray(s))
return "";
const e = s.slug;
@@ -595,10 +595,10 @@ function ke(s) {
function $e(s) {
return Math.max(0, Math.round(s));
}
function wt(s) {
function xt(s) {
return s.split(/[-_]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" ");
}
function kt(s, e) {
function wt(s, e) {
const t = V(e, {
year: "numeric",
month: "2-digit",
@@ -620,7 +620,7 @@ function kt(s, e) {
);
return Number.isFinite(i) ? i - s : 0;
}
function $t(s, e) {
function kt(s, e) {
if (typeof s != "string")
return null;
const t = s.split("-");
@@ -633,17 +633,17 @@ function $t(s, e) {
const o = new Date(n, i - 1, a);
return Number.isNaN(o.getTime()) ? null : o;
}
const r = Date.UTC(n, i - 1, a), l = new Date(r - kt(r, e));
const r = Date.UTC(n, i - 1, a), l = new Date(r - wt(r, e));
return Number.isNaN(l.getTime()) ? null : l;
}
function qt(s) {
const e = bt[s];
function $t(s) {
const e = yt[s];
return e || s.charAt(0).toUpperCase() + s.slice(1);
}
function Ct(s, e) {
return vt[s] ?? e;
function qt(s, e) {
return bt[s] ?? e;
}
const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", At = He`
const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ct = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Mt = Ie`
* {
box-sizing: border-box;
}
@@ -715,7 +715,7 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
justify-content: center;
width: 34px;
height: 34px;
clip-path: ${v(Mt)};
clip-path: ${v(Ct)};
background: var(--nq-brand-gradient);
}
@@ -1038,7 +1038,7 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
font-weight: 800;
line-height: 1;
}
`, St = d`<svg
`, At = d`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1049,7 +1049,7 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
>
<circle cx="12" cy="12" r="10"></circle>
<path d="m9 12 2 2 4-4"></path>
</svg>`, Tt = d`<svg
</svg>`, St = d`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1060,7 +1060,7 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
>
<path d="M3.5 21 12 3.5 20.5 21"></path>
<path d="M9 21l3-8 3 8"></path>
</svg>`, zt = d`<svg
</svg>`, Tt = d`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1071,7 +1071,7 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
>
<circle cx="12" cy="12" r="10"></circle>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon>
</svg>`, Et = d`<svg
</svg>`, zt = d`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1083,7 +1083,7 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>`, Nt = d`<svg
</svg>`, Et = d`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1095,14 +1095,14 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"></path>
<path d="m2 2 20 20"></path>
</svg>`, Ce = {
icon: Et,
icon: zt,
headline: "NestQuest is not set up yet",
body: "A parent needs to finish setting up NestQuest before the party can gather."
}, Lt = {
icon: Nt,
}, Nt = {
icon: Et,
headline: "The records cannot be reached",
body: "The party's records are quiet right now. NestQuest will return shortly."
}, Bt = d`<svg
}, Lt = d`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1118,13 +1118,13 @@ const qe = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Mt = "polygon(50%
<path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"></path>
<path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"></path>
</svg>`;
class Ot extends M {
class Bt extends M {
static properties = {
hass: { attribute: !1 },
_config: { state: !0 },
_now: { state: !0 }
};
static styles = [v(Ve), At];
static styles = [v(Qe), Mt];
hass;
_config;
_now = /* @__PURE__ */ new Date();
@@ -1156,7 +1156,7 @@ class Ot extends M {
t === this._subGeneration && (this._unsubscribeLive(), this._armSubscribeRetry());
});
};
for (const i of gt)
for (const i of mt)
n(
e.subscribeEvents(() => this.requestUpdate(), i)
);
@@ -1219,7 +1219,7 @@ class Ot extends M {
`;
}
_timeZone() {
return _t(this.hass);
return gt(this.hass);
}
_state(e) {
const n = this.hass?.states?.[e];
@@ -1243,7 +1243,7 @@ class Ot extends M {
const t = this._state(
"sensor.nestquest_household_quests_due_today"
)?.attributes?.child_roster;
return Array.isArray(t) ? t.map((n) => xt(n)).filter((n) => n.length > 0) : [];
return Array.isArray(t) ? t.map((n) => vt(n)).filter((n) => n.length > 0) : [];
}
_plates() {
return this._childSlugs().map((e) => this._plate(e));
@@ -1267,8 +1267,8 @@ class Ot extends M {
const G = r?.attributes?.child_name;
typeof G == "string" && G.trim() && (m = G.trim());
}
m || (m = wt(e));
const x = $t(
m || (m = xt(e));
const x = kt(
r?.attributes?.next_present,
this._timeZone()
), R = x ? `Returns ${J(x, this._timeZone())}` : null;
@@ -1297,7 +1297,7 @@ class Ot extends M {
}
_boardNotice() {
const e = this._plates();
return e.length === 0 ? Ce : e.every((t) => t.unresolved !== null) ? e.some((t) => t.unresolved === "stale") ? Lt : Ce : null;
return e.length === 0 ? Ce : e.every((t) => t.unresolved !== null) ? e.some((t) => t.unresolved === "stale") ? Nt : Ce : null;
}
_renderNotice(e) {
return d`
@@ -1320,7 +1320,7 @@ class Ot extends M {
</span>
<span class="name">${e.name}</span>
<span class="pill away">
${zt}
${Tt}
<span>Unknown</span>
</span>
<span class="progress-line">&mdash;</span>
@@ -1340,7 +1340,7 @@ class Ot extends M {
</span>
<span class="name">${e.name}</span>
<span class="pill">
${St}
${At}
<span>Home today</span>
</span>
<span class="progress-line">
@@ -1359,7 +1359,7 @@ class Ot extends M {
</span>
<span class="name">${e.name}</span>
<span class="pill away">
${Tt}
${St}
<span>On travels</span>
</span>
<span class="progress-line">${e.returns ?? "Returns —"}</span>
@@ -1411,13 +1411,13 @@ class Ot extends M {
<div class="dock">
<span class="date">${J(this._now, this._timeZone())}</span>
<span class="divider"></span>
<span class="clock">${yt(this._now, this._timeZone())}</span>
<span class="clock">${_t(this._now, this._timeZone())}</span>
</div>
`;
const t = qt(e.condition), n = Ct(e.condition, t), i = e.temperature === null ? g : d`<span class="temp">${Math.round(e.temperature)}°</span>`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`;
const t = $t(e.condition), n = qt(e.condition, t), i = e.temperature === null ? g : d`<span class="temp">${Math.round(e.temperature)}°</span>`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`;
return d`
<div class="dock">
${Bt}
${Lt}
${i}
<span class="divider"></span>
<span class="condition">
@@ -1429,14 +1429,14 @@ class Ot extends M {
`;
}
}
customElements.define("nestquest-party-board-card", Ot);
customElements.define("nestquest-party-board-card", Bt);
le({
type: "nestquest-party-board-card",
name: "NestQuest Party Board",
description: "Kids' attract screen for present and away adventurers."
});
const Pt = { CHILD: 2 }, It = (s) => (...e) => ({ _$litDirective$: s, values: e });
let Ht = class {
const Ot = { CHILD: 2 }, Pt = (s) => (...e) => ({ _$litDirective$: s, values: e });
let It = class {
constructor(e) {
}
get _$AU() {
@@ -1452,11 +1452,11 @@ let Ht = class {
return this.render(...t);
}
};
const { I: jt } = ht, Me = (s) => s, Ae = () => document.createComment(""), B = (s, e, t) => {
const { I: Ht } = pt, Me = (s) => s, Ae = () => document.createComment(""), B = (s, e, t) => {
const n = s._$AA.parentNode, i = e === void 0 ? s._$AB : e._$AA;
if (t === void 0) {
const a = n.insertBefore(Ae(), i), r = n.insertBefore(Ae(), i);
t = new jt(a, r, s, s.options);
t = new Ht(a, r, s, s.options);
} else {
const a = t._$AB.nextSibling, r = t._$AM, l = r !== s;
if (l) {
@@ -1472,16 +1472,16 @@ const { I: jt } = ht, Me = (s) => s, Ae = () => document.createComment(""), B =
}
}
return t;
}, q = (s, e, t = s) => (s._$AI(e, t), s), Rt = {}, Ut = (s, e = Rt) => s._$AH = e, Dt = (s) => s._$AH, Y = (s) => {
}, q = (s, e, t = s) => (s._$AI(e, t), s), jt = {}, Rt = (s, e = jt) => s._$AH = e, Ut = (s) => s._$AH, Y = (s) => {
s._$AR(), s._$AA.remove();
};
const Se = (s, e, t) => {
const n = /* @__PURE__ */ new Map();
for (let i = e; i <= t; i++) n.set(s[i], i);
return n;
}, Te = It(class extends Ht {
}, Te = Pt(class extends It {
constructor(s) {
if (super(s), s.type !== Pt.CHILD) throw Error("repeat() can only be used in text expressions");
if (super(s), s.type !== Ot.CHILD) throw Error("repeat() can only be used in text expressions");
}
dt(s, e, t) {
let n;
@@ -1495,7 +1495,7 @@ const Se = (s, e, t) => {
return this.dt(s, e, t).values;
}
update(s, [e, t, n]) {
const i = Dt(s), { values: a, keys: r } = this.dt(e, t, n);
const i = Ut(s), { values: a, keys: r } = this.dt(e, t, n);
if (!Array.isArray(i)) return this.ut = r, a;
const l = this.ut ??= [], o = [];
let p, u, c = 0, f = i.length - 1, h = 0, m = a.length - 1;
@@ -1522,9 +1522,9 @@ const Se = (s, e, t) => {
const _ = i[c++];
_ !== null && Y(_);
}
return this.ut = r, Ut(s, o), S;
return this.ut = r, Rt(s, o), S;
}
}), Ft = [
}), Dt = [
{
kind: "lucide",
key: "lucide:bed",
@@ -1994,7 +1994,7 @@ const Se = (s, e, t) => {
"m3 7 2 2 4-4"
]
}
], Qt = [
], Ft = [
{
kind: "fa",
key: "fa:broom",
@@ -2215,20 +2215,20 @@ const Se = (s, e, t) => {
"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"
]
}
], O = { kind: "fallback", key: null }, Vt = 8, ze = new Map(Ft.map((s) => [s.name, s])), Zt = new Map(Qt.map((s) => [s.name, s]));
function Wt(s) {
], O = { kind: "fallback", key: null }, Qt = 8, ze = new Map(Dt.map((s) => [s.name, s])), Vt = new Map(Ft.map((s) => [s.name, s]));
function Zt(s) {
const e = [...s];
return e.length === 0 || e.length > Vt ? !1 : !e.some((t) => new RegExp("[\\s<>&]|\\p{Cc}", "u").test(t));
return e.length === 0 || e.length > Qt ? !1 : !e.some((t) => new RegExp("[\\s<>&]|\\p{Cc}", "u").test(t));
}
function Gt(s) {
function Wt(s) {
if (!s || !s.trim()) return O;
const e = s.indexOf(":");
if (e === -1) return ze.get(s) ?? O;
const t = s.slice(0, e), n = s.slice(e + 1);
return t === "lucide" ? ze.get(n) ?? O : t === "fa" ? Zt.get(n) ?? O : t === "emoji" && Wt(n) ? { kind: "emoji", key: s, text: n } : O;
return t === "lucide" ? ze.get(n) ?? O : t === "fa" ? Vt.get(n) ?? O : t === "emoji" && Zt(n) ? { kind: "emoji", key: s, text: n } : O;
}
function Ee(s, e) {
const t = Gt(s);
function Gt(s, e) {
const t = Wt(s);
switch (t.kind) {
case "lucide":
return d`<svg
@@ -2263,9 +2263,9 @@ const Kt = [
"nestquest_quest_uncompleted",
"nestquest_quest_missed",
"nestquest_child_day_complete"
], Ne = /* @__PURE__ */ new Map();
], Ee = /* @__PURE__ */ new Map();
function E(s, e) {
const t = `${s ?? "local"}|${JSON.stringify(e)}`, n = Ne.get(t);
const t = `${s ?? "local"}|${JSON.stringify(e)}`, n = Ee.get(t);
if (n)
return n;
let i;
@@ -2277,7 +2277,7 @@ function E(s, e) {
} catch {
i = new Intl.DateTimeFormat("en-US", e);
}
return Ne.set(t, i), i;
return Ee.set(t, i), i;
}
function Jt(s) {
const e = s?.config;
@@ -2356,7 +2356,7 @@ function nn(s, e) {
);
return Number.isFinite(i) ? i - s : 0;
}
function Le(s, e) {
function Ne(s, e) {
if (typeof s != "string")
return null;
const t = s.split("-");
@@ -2439,7 +2439,7 @@ function ln(s) {
function cn(s, e) {
return on[s] ?? e;
}
const Be = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", dn = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Oe = [-9, 6, -4], pn = He`
const Le = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", dn = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", Be = [-9, 6, -4], pn = Ie`
* {
box-sizing: border-box;
}
@@ -2499,7 +2499,7 @@ const Be = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", dn = "polygon(50%
width: 96px;
height: 110px;
padding: 3px;
clip-path: ${v(Be)};
clip-path: ${v(Le)};
background: rgba(255, 255, 255, 0.22);
}
@@ -2510,7 +2510,7 @@ const Be = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", dn = "polygon(50%
align-items: center;
justify-content: center;
padding-bottom: 32px;
clip-path: ${v(Be)};
clip-path: ${v(Le)};
background: var(--nq-p-crest);
}
@@ -3341,7 +3341,7 @@ const Be = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", dn = "polygon(50%
<path d="M22 22H2"></path>
<path d="m8 6 4-4 4 4"></path>
<path d="M16 18a4 4 0 0 0-8 0"></path>
</svg>`, Ze = d`<svg
</svg>`, Ve = d`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -3437,9 +3437,9 @@ const Be = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", dn = "polygon(50%
icon: yn,
headline: "The records cannot be reached",
body: "The party's records are quiet right now. NestQuest will return shortly."
}, Pe = [
}, Oe = [
{ key: "morning", name: "Morning", range: "Until 11:59 AM", icon: un },
{ key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ze },
{ key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ve },
{ key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: fn }
];
class kn extends M {
@@ -3452,7 +3452,7 @@ class kn extends M {
_toast: { state: !0 },
_countdown: { state: !0 }
};
static styles = [v(Ve), pn];
static styles = [v(Qe), pn];
hass;
_config;
_now = /* @__PURE__ */ new Date();
@@ -3570,7 +3570,7 @@ class kn extends M {
${this._renderHeader(e)}
<div class="notice-wrap">
<div class="notice" role="status">
${Ze}
${Ve}
<span class="notice-headline">No quests today</span>
<p class="notice-body">
${`Nothing was posted for today, ${e.name}. Enjoy the day's rest!`}
@@ -3614,7 +3614,7 @@ class kn extends M {
return d`
${this._renderHeader(e)}
<div class="columns">
${Pe.map((t) => this._renderColumn(t))}
${Oe.map((t) => this._renderColumn(t))}
</div>
`;
}
@@ -3662,7 +3662,7 @@ class kn extends M {
_awayReturns(e) {
const t = this._timeZone(), n = this._state(
`binary_sensor.nestquest_${e}_present_today`
), i = Le(
), i = Ne(
n?.attributes?.next_present,
t
);
@@ -3808,7 +3808,7 @@ class kn extends M {
if (!u) {
const _ = this._state(
`binary_sensor.nestquest_${l}_present_today`
), x = Le(
), x = Ne(
_?.attributes?.next_present,
n
);
@@ -3915,10 +3915,10 @@ class kn extends M {
}
_renderQuest(e, t, n) {
if (t) {
const r = Oe[n % Oe.length], l = e.completed_at ? Yt(e.completed_at, this._timeZone()) : "";
const r = Be[n % Be.length], l = e.completed_at ? Yt(e.completed_at, this._timeZone()) : "";
return d`
<div class="quest sealed" data-instance-id=${e.id}>
<span class="tile" aria-hidden="true">${Ee(e.icon, te)}</span>
<span class="tile" aria-hidden="true">${te}</span>
<div class="body">
<span class="quest-title">${e.title}</span>
<span class="meta">
@@ -3954,7 +3954,7 @@ class kn extends M {
(r.key === "Enter" || r.key === " ") && (r.preventDefault(), this._openConfirm(e.id));
}}
>
<span class="tile" aria-hidden="true">${Ee(e.icon, te)}</span>
<span class="tile" aria-hidden="true">${Gt(e.icon, te)}</span>
<div class="body">
<span class="quest-title">${e.title}</span>
<span class="meta ${a.late ? "late" : g}">${a.text}</span>
@@ -3990,7 +3990,7 @@ class kn extends M {
);
if (!e)
return g;
const t = Pe.find((n) => n.key === e.window);
const t = Oe.find((n) => n.key === e.window);
return d`
<div class="scrim" @click=${() => this._closeConfirm()}>
<div
@@ -4231,7 +4231,7 @@ le({
name: "NestQuest Admin",
description: "Parent phone view for today, tasks, schedule, and history."
});
function We(s) {
function Ze(s) {
return typeof s == "string" ? s.trim() : "";
}
function Cn(s) {
@@ -4241,7 +4241,7 @@ function Cn(s) {
return typeof e == "string" ? e.trim() : "";
}
function Mn(s) {
const e = We(s.url_path).replace(/^\/+|\/+$/g, "");
const e = Ze(s.url_path).replace(/^\/+|\/+$/g, "");
return e || (window.location.pathname.split("/").filter(Boolean)[0] ?? "");
}
function An(s) {
@@ -4256,7 +4256,7 @@ function Sn(s) {
}
class Tn {
static async generate(e, t) {
const n = `/${Mn(e)}`, i = We(e.weather_entity), a = {
const n = `/${Mn(e)}`, i = Ze(e.weather_entity), a = {
type: "custom:nestquest-party-board-card",
quest_log_path: n
};
+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`.
+1 -1
View File
@@ -2057,7 +2057,7 @@ export class NestQuestQuestLogCard extends LitElement {
: "";
return html`
<div class="quest sealed" data-instance-id=${instance.id}>
<span class="tile" aria-hidden="true">${renderQuestIcon(instance.icon, ICON_STAR)}</span>
<span class="tile" aria-hidden="true">${ICON_STAR}</span>
<div class="body">
<span class="quest-title">${instance.title}</span>
<span class="meta">
+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;
+41
View File
@@ -6,6 +6,9 @@ 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 (
@@ -70,6 +73,8 @@ def test_validate_icon_clears(value: str | None) -> None:
"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
@@ -109,3 +114,39 @@ def test_validate_icon_rejects_non_strings(value: object) -> None:
)
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
+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:]
+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 &" }
]
}