feat(domain,frontend): remplace le catalogue d'icônes projet par des emojis Unicode système — #345 (QA verte)

Reprend à zéro la liste des icônes projet: catalogue d'emojis Unicode rendus
par la police système, sans embarquer d'assets graphiques propriétaires.
- domain: variante `emoji` ajoutée à ProjectIconSet (serde kebab-case)
- ports TS: union ProjectIconSet alignée avec "emoji"
- frontend: catalogue réécrit en 7 groupes (smileys, nature, food,
  activities, travel, objects, symbols), registres légaux et métadonnées
  supprimés, assets SVG projet retirés (angular.svg, NOTICE.md)
- watermark CLI custom: rendu emoji direct avec libellé de repli dérivé du
  nom de dossier du cwd
- i18n FR/EN des groupes du picker et tests mis à jour (domain settings,
  adapter projet, picker, panneau settings, chat custom)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 10:52:29 +02:00
parent 1006f62001
commit ff1d9af53a
14 changed files with 304 additions and 280 deletions

View File

@ -38,6 +38,8 @@ pub struct IdeaUserSettings {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum ProjectIconSet { pub enum ProjectIconSet {
/// Unicode emoji catalogue.
Emoji,
/// Devicon language and platform catalogue. /// Devicon language and platform catalogue.
Devicon, Devicon,
/// Simple Icons brand catalogue. /// Simple Icons brand catalogue.

View File

@ -45,6 +45,7 @@ fn language_roundtrips_as_stable_codes_and_rejects_unknown_values() {
#[test] #[test]
fn project_icon_roundtrips_for_every_known_set() { fn project_icon_roundtrips_for_every_known_set() {
for (set, wire) in [ for (set, wire) in [
(ProjectIconSet::Emoji, "emoji"),
(ProjectIconSet::Devicon, "devicon"), (ProjectIconSet::Devicon, "devicon"),
(ProjectIconSet::SimpleIcons, "simple-icons"), (ProjectIconSet::SimpleIcons, "simple-icons"),
(ProjectIconSet::Lucide, "lucide"), (ProjectIconSet::Lucide, "lucide"),
@ -64,6 +65,19 @@ fn project_icon_roundtrips_for_every_known_set() {
} }
} }
#[test]
fn legacy_project_icon_sets_remain_accepted() {
for wire in ["devicon", "simple-icons", "lucide", "idea"] {
let icon: ProjectIconRef = serde_json::from_value(serde_json::json!({
"set": wire,
"name": "legacy-icon"
}))
.unwrap();
assert_eq!(icon.name(), "legacy-icon");
}
}
#[test] #[test]
fn project_icon_rejects_unknown_sets_and_unstable_or_oversized_names() { fn project_icon_rejects_unknown_sets_and_unstable_or_oversized_names() {
assert!( assert!(

View File

@ -67,12 +67,12 @@ describe("TauriProjectGateway invoke payloads", () => {
it("passes project icon updates through the additive settings request", async () => { it("passes project icon updates through the additive settings request", async () => {
const gateway = new TauriProjectGateway(); const gateway = new TauriProjectGateway();
await gateway.updateProjectSettings("proj-1", { await gateway.updateProjectSettings("proj-1", {
icon: { set: "devicon", name: "typescript" }, icon: { set: "emoji", name: "rocket" },
}); });
expect(invoke).toHaveBeenCalledWith("update_project_settings", { expect(invoke).toHaveBeenCalledWith("update_project_settings", {
request: { request: {
projectId: "proj-1", projectId: "proj-1",
icon: { set: "devicon", name: "typescript" }, icon: { set: "emoji", name: "rocket" },
}, },
}); });
}); });

View File

@ -1,13 +0,0 @@
# Project icon notices
## Angular
The vendored `angular.svg` is the Angular icon supplied by the official
[Angular Press Kit](https://angular.dev/press-kit).
Copyright Google LLC. Licensed under
[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/).
No Unity, Unreal Engine, Windows, Apple, Google, or Microsoft logo artwork is
bundled. Their searchable catalogue entries resolve to product-owned generic
symbols.

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Angular icon, © Google LLC, CC BY 4.0. Source: https://angular.dev/press-kit -->
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="960" viewBox="0 0 960 960">
<g fill="currentColor">
<polygon points="562.6,109.8 804.1,629.5 829.2,233.1"/>
<polygon points="624.9,655.9 334.3,655.9 297.2,745.8 479.6,849.8 662,745.8"/>
<polygon points="384.1,539.3 575.2,539.3 479.6,307"/>
<polygon points="396.6,109.8 130,233.1 155.1,629.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 512 B

View File

@ -213,9 +213,9 @@ describe("CustomAgentChatView", () => {
expect(watermark.getAttribute("aria-hidden")).toBe("true"); expect(watermark.getAttribute("aria-hidden")).toBe("true");
expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%"); expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%");
expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%"); expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%");
expect(watermark.textContent).toBe("Py"); expect(watermark.textContent).toBe("🐍");
publishProjectIconChanged({ projectId: "project-1", icon: { set: "lucide", name: "star" } }); publishProjectIconChanged({ projectId: "project-1", icon: { set: "emoji", name: "star" } });
await waitFor(() => expect(watermark.textContent).toBe("★")); await waitFor(() => expect(watermark.textContent).toBe("⭐"));
}); });
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => { it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {

View File

@ -44,7 +44,7 @@ import {
markProjectCliInteractionStopped, markProjectCliInteractionStopped,
} from "@/features/projects/projectCliInteractionActivity"; } from "@/features/projects/projectCliInteractionActivity";
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared"; import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
import { onProjectIconChanged, ProjectIcon, resolveProjectIcon } from "@/features/projects/projectIcons"; import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
import type { import type {
ChatAttachmentInput, ChatAttachmentInput,
ChatDraftScope, ChatDraftScope,
@ -2478,9 +2478,7 @@ export function CustomAgentChatView({
} }
} }
const projectWatermarkRef = projectIcon && resolveProjectIcon(projectIcon, "watermark") const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId;
? projectIcon
: null;
return ( return (
<div <div
@ -2488,22 +2486,21 @@ export function CustomAgentChatView({
data-testid="custom-agent-chat-view" data-testid="custom-agent-chat-view"
className="relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content" className="relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
> >
{projectWatermarkRef && ( <div
<div data-testid="project-icon-watermark"
data-testid="project-icon-watermark" aria-hidden="true"
aria-hidden="true" className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]"
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]" style={{ pointerEvents: "none", containerType: "size" }}
style={{ pointerEvents: "none", containerType: "size" }} >
> <ProjectIcon
<ProjectIcon icon={projectIcon}
icon={projectWatermarkRef} fallbackLabel={projectFallbackLabel}
surface="watermark" surface="watermark"
size="92%" size="92%"
decorative decorative
style={{ fontSize: "min(34cqw, 34cqh)", maxWidth: "92cqh", maxHeight: "92cqw" }} style={{ fontSize: "min(34cqw, 34cqh)", maxWidth: "92cqh", maxHeight: "92cqw" }}
/> />
</div> </div>
)}
<div <div
role="toolbar" role="toolbar"
aria-label={t("custom.actions")} aria-label={t("custom.actions")}

View File

@ -6,14 +6,18 @@ import {
PROJECT_ICON_CATALOG, PROJECT_ICON_CATALOG,
ProjectIcon, ProjectIcon,
type ProjectIconGroup, type ProjectIconGroup,
toEmojiIconRef,
} from "./projectIcons"; } from "./projectIcons";
const GROUPS: readonly ProjectIconGroup[] = ["platforms", "technologies", "symbols", "letters"]; const GROUPS: readonly ProjectIconGroup[] = ["smileys", "nature", "food", "activities", "travel", "objects", "symbols"];
const GROUP_LABELS = { const GROUP_LABELS = {
platforms: "project.icon.group.platforms", smileys: "project.icon.group.smileys",
technologies: "project.icon.group.technologies", nature: "project.icon.group.nature",
food: "project.icon.group.food",
activities: "project.icon.group.activities",
travel: "project.icon.group.travel",
objects: "project.icon.group.objects",
symbols: "project.icon.group.symbols", symbols: "project.icon.group.symbols",
letters: "project.icon.group.letters",
} as const; } as const;
export function ProjectIconPicker({ value, onApply, onCancel }: { export function ProjectIconPicker({ value, onApply, onCancel }: {
@ -23,7 +27,7 @@ export function ProjectIconPicker({ value, onApply, onCancel }: {
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selected, setSelected] = useState<ProjectIconRef | null>(value); const [selected, setSelected] = useState<ProjectIconRef | null>(() => toEmojiIconRef(value));
const results = useMemo(() => { const results = useMemo(() => {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word)))); return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word))));

View File

@ -278,14 +278,14 @@ describe("ProjectSettingsPanel", () => {
fireEvent.click(await screen.findByRole("button", { name: "Choisir une icône" })); fireEvent.click(await screen.findByRole("button", { name: "Choisir une icône" }));
const search = screen.getByLabelText("Search icons in English"); const search = screen.getByLabelText("Search icons in English");
fireEvent.change(search, { target: { value: "docker" } }); fireEvent.change(search, { target: { value: "docker" } });
const docker = await screen.findByRole("option", { name: "Docker" }); const packageEmoji = await screen.findByRole("option", { name: "Package" });
fireEvent.click(docker); fireEvent.click(packageEmoji);
expect(docker.getAttribute("aria-selected")).toBe("true"); expect(packageEmoji.getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Appliquer" })); fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
await waitFor(() => await waitFor(() =>
expect(update).toHaveBeenCalledWith(project.id, { expect(update).toHaveBeenCalledWith(project.id, {
icon: { set: "simple-icons", name: "docker" }, icon: { set: "emoji", name: "package" },
}), }),
); );
expect(await screen.findByRole("button", { name: "Modifier l’icône" })).toBeTruthy(); expect(await screen.findByRole("button", { name: "Modifier l’icône" })).toBeTruthy();

View File

@ -22,6 +22,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
const { t } = useI18n(); const { t } = useI18n();
const [themeColor, setThemeColor] = useState<string | null>(null); const [themeColor, setThemeColor] = useState<string | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null); const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const [projectName, setProjectName] = useState("");
const [iconPickerOpen, setIconPickerOpen] = useState(false); const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [busy, setBusy] = useState(true); const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -74,6 +75,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
}; };
}, [load]); }, [load]);
useEffect(() => {
let cancelled = false;
void project.listProjects().then((projects) => {
if (!cancelled) setProjectName(projects.find(({ id }) => id === projectId)?.name ?? "");
}).catch(() => undefined);
return () => { cancelled = true; };
}, [project, projectId]);
useEffect( useEffect(
() => () =>
onProjectThemeChanged( onProjectThemeChanged(
@ -188,7 +197,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4"> <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
<div className="flex items-center gap-3 text-sm font-medium text-content"> <div className="flex items-center gap-3 text-sm font-medium text-content">
{projectIcon ? <ProjectIcon icon={projectIcon} size={38} /> : <span className="flex h-9 w-9 items-center justify-center rounded border border-dashed border-border text-muted">—</span>} <ProjectIcon icon={projectIcon} fallbackLabel={projectName || projectId} size={38} />
<span>{t("project.icon")}</span> <span>{t("project.icon")}</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">

View File

@ -7,86 +7,81 @@ import {
ProjectIcon, ProjectIcon,
findProjectIcon, findProjectIcon,
resolveProjectIcon, resolveProjectIcon,
toEmojiIconRef,
} from "./projectIcons"; } from "./projectIcons";
describe("project icon catalogue", () => { describe("project emoji catalogue", () => {
it("carries legal policy for every entry and an attribution for the vendored Angular artwork", () => { it("contains only stable Unicode emoji references and no bundled artwork metadata", () => {
expect(PROJECT_ICON_CATALOG.length).toBeGreaterThan(70); expect(PROJECT_ICON_CATALOG.length).toBeGreaterThan(90);
for (const entry of PROJECT_ICON_CATALOG) { for (const entry of PROJECT_ICON_CATALOG) {
expect(entry.legal.owner).toBeTruthy(); expect(entry.set).toBe("emoji");
expect(entry.legal.license).toBeTruthy(); expect(entry.name).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
expect(typeof entry.legal.productUiAllowed).toBe("boolean"); expect(entry.glyph).toBeTruthy();
expect(typeof entry.legal.watermarkAllowed).toBe("boolean"); expect(entry).not.toHaveProperty("assetSrc");
expect(entry).not.toHaveProperty("legal");
} }
const angular = findProjectIcon({ set: "simple-icons", name: "angular" });
expect(angular).toMatchObject({
group: "technologies",
legal: {
license: "CC BY 4.0",
productUiAllowed: true,
watermarkAllowed: true,
sourceUrl: "https://angular.dev/press-kit",
},
});
expect(angular?.assetSrc).toBeTruthy();
}); });
it.each([ it("resolves known legacy references to emoji and unknown references to an initial", () => {
["unity", "gamepad-2"], expect(resolveProjectIcon({ set: "devicon", name: "python" })?.glyph).toBe("🐍");
["unrealengine", "gamepad-2"], expect(toEmojiIconRef({ set: "lucide", name: "star" })).toEqual({ set: "emoji", name: "star" });
["windows", "terminal"], expect(findProjectIcon({ set: "devicon", name: "python" })).toBeNull();
["apple", "terminal"],
["google", "globe-2"], const { getByRole } = render(<ProjectIcon icon={{ set: "devicon", name: "unknown-tool" }} />);
["microsoft", "terminal"], expect(getByRole("img", { name: "U" }).textContent).toBe("U");
])("resolves the restricted %s brand to product-owned fallback artwork", (name, fallbackName) => { expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial");
const set = name === "windows" || name === "apple" ? "devicon" : "simple-icons";
const restricted = findProjectIcon({ set, name });
expect(restricted?.legal.productUiAllowed).toBe(false);
expect(restricted?.legal.watermarkAllowed).toBe(false);
expect(resolveProjectIcon({ set, name }, "product-ui")?.name).toBe(fallbackName);
expect(resolveProjectIcon({ set, name }, "watermark")?.name).toBe(fallbackName);
}); });
it("provides stable, strongly boxed A-Z monograms", () => { it("keeps legacy alphabet initials readable", () => {
const letters = PROJECT_ICON_CATALOG.filter((entry) => entry.group === "letters");
expect(letters.map((entry) => entry.glyph).join("")).toBe("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
const { getByRole } = render(<ProjectIcon icon={{ set: "idea", name: "letter-z" }} />); const { getByRole } = render(<ProjectIcon icon={{ set: "idea", name: "letter-z" }} />);
expect(getByRole("img", { name: "Z" }).style.borderTopWidth).toBe("1px"); expect(getByRole("img", { name: "Z" }).textContent).toBe("Z");
expect(getByRole("img", { name: "Z" }).style.borderRadius).toBe("18%"); });
it("uses the project initial when no icon is configured", () => {
const { getByRole } = render(<ProjectIcon icon={null} fallbackLabel="Alpha project" />);
expect(getByRole("img", { name: "A" }).textContent).toBe("A");
expect(getByRole("img").dataset.projectIcon).toBe("none");
}); });
}); });
describe("ProjectIconPicker", () => { describe("ProjectIconPicker", () => {
function renderPicker() { function renderPicker(value = null as Parameters<typeof ProjectIconPicker>[0]["value"]) {
render(<ProjectIconPicker value={null} onApply={vi.fn()} onCancel={vi.fn()} />); render(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
} }
it("shows the four catalogue groups", () => { it("shows phone-like emoji categories only", () => {
renderPicker(); renderPicker();
expect(screen.getByText("Plateformes")).toBeTruthy(); for (const label of ["Smileys", "Animaux et nature", "Nourriture et boissons", "Activités", "Voyages et lieux", "Objets", "Symboles"]) {
expect(screen.getByText("Technologies / Frameworks")).toBeTruthy(); expect(screen.getByText(label)).toBeTruthy();
expect(screen.getByText("Symboles / Génériques")).toBeTruthy(); }
expect(screen.getByText("Lettres")).toBeTruthy(); expect(screen.queryByText("Technologies / Frameworks")).toBeNull();
expect(screen.queryByText("Lettres")).toBeNull();
}); });
it.each([ it.each([
["unity", "Unity"], ["python", "Snake"],
["unreal", "Unreal Engine"], ["docker", "Package"],
["angular", "Angular"], ["space launch", "Rocket"],
["windows", "Windows"], ["database", "Floppy disk"],
])("finds %s using the English search metadata", (query, label) => { ])("finds %s using the English search metadata", (query, label) => {
renderPicker(); renderPicker();
fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: query } }); fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: query } });
expect(screen.getByRole("option", { name: label })).toBeTruthy(); expect(screen.getByRole("option", { name: label })).toBeTruthy();
}); });
it("finds and selects an alphabet monogram", () => { it("selects and persists an emoji reference", () => {
const onApply = vi.fn(); const onApply = vi.fn();
render(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />); render(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "letter q" } }); fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "rocket" } });
fireEvent.click(screen.getByRole("option", { name: "Q" })); fireEvent.click(screen.getByRole("option", { name: "Rocket" }));
fireEvent.click(screen.getByRole("button", { name: "Appliquer" })); fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
expect(onApply).toHaveBeenCalledWith({ set: "idea", name: "letter-q" }); expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "rocket" });
});
it("normalizes a known legacy selection before applying it", () => {
const onApply = vi.fn();
render(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "snake" });
}); });
}); });

View File

@ -1,211 +1,231 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import angularLogo from "@/assets/project-icons/angular.svg"; import type { ProjectIconRef } from "@/ports";
import type { ProjectIconRef, ProjectIconSet } from "@/ports";
export type ProjectIconGroup = "platforms" | "technologies" | "symbols" | "letters"; export type ProjectIconGroup =
| "smileys"
| "nature"
| "food"
| "activities"
| "travel"
| "objects"
| "symbols";
export type ProjectIconSurface = "product-ui" | "watermark"; export type ProjectIconSurface = "product-ui" | "watermark";
export interface ProjectIconLegalMetadata {
owner: string;
license: string;
sourceUrl?: string;
attribution?: string;
productUiAllowed: boolean;
watermarkAllowed: boolean;
fallback?: ProjectIconRef;
}
export interface ProjectIconCatalogEntry extends ProjectIconRef { export interface ProjectIconCatalogEntry extends ProjectIconRef {
set: "emoji";
label: string; label: string;
group: ProjectIconGroup; group: ProjectIconGroup;
glyph: string; glyph: string;
assetSrc?: string;
keywords: readonly string[]; keywords: readonly string[];
legal: ProjectIconLegalMetadata;
} }
const OWNED: ProjectIconLegalMetadata = { const emoji = (
owner: "IdeA",
license: "Product-owned generic artwork",
productUiAllowed: true,
watermarkAllowed: true,
};
const generic = (
set: ProjectIconSet,
name: string, name: string,
label: string, label: string,
glyph: string, glyph: string,
group: ProjectIconGroup, group: ProjectIconGroup,
keywords: string[] = [], keywords: string[] = [],
): ProjectIconCatalogEntry => ({ ): ProjectIconCatalogEntry => ({
set, name, label, glyph, group, set: "emoji",
name,
label,
glyph,
group,
keywords: [label, name, ...keywords].map((word) => word.toLowerCase()), keywords: [label, name, ...keywords].map((word) => word.toLowerCase()),
legal: OWNED,
}); });
const restrictedBrand = ( /** Curated Unicode characters only. Their artwork is supplied by the operating system. */
set: ProjectIconSet,
name: string,
label: string,
group: ProjectIconGroup,
fallback: ProjectIconRef,
keywords: string[] = [],
): ProjectIconCatalogEntry => ({
set, name, label, group, glyph: "",
keywords: [label, name, ...keywords].map((word) => word.toLowerCase()),
legal: {
owner: label,
license: "No bundled trademark artwork; generic fallback only",
productUiAllowed: false,
watermarkAllowed: false,
fallback,
},
});
const GAME = { set: "lucide", name: "gamepad-2" } as const;
const GLOBE = { set: "lucide", name: "globe-2" } as const;
const TERMINAL = { set: "lucide", name: "terminal" } as const;
const TECHNOLOGY_MONOGRAMS: readonly ProjectIconCatalogEntry[] = [
generic("devicon", "c", "C", "C", "technologies", ["language"]),
generic("devicon", "cplusplus", "C++", "C++", "technologies", ["cpp language"]),
generic("devicon", "csharp", "C#", "C#", "technologies", ["dotnet language"]),
generic("devicon", "java", "Java", "J", "technologies", ["language jvm"]),
generic("devicon", "javascript", "JavaScript", "JS", "technologies", ["language js"]),
generic("devicon", "typescript", "TypeScript", "TS", "technologies", ["language ts"]),
generic("devicon", "python", "Python", "Py", "technologies", ["language"]),
generic("devicon", "rust", "Rust", "Rs", "technologies", ["language cargo"]),
generic("devicon", "go", "Go", "Go", "technologies", ["language golang"]),
generic("devicon", "kotlin", "Kotlin", "Kt", "technologies", ["language jvm"]),
generic("devicon", "swift", "Swift", "Sw", "technologies", ["language ios"]),
generic("devicon", "php", "PHP", "PHP", "technologies", ["language"]),
generic("devicon", "ruby", "Ruby", "Rb", "technologies", ["language rails"]),
generic("simple-icons", "github", "GitHub", "GH", "technologies", ["git repository monogram"]),
generic("simple-icons", "gitlab", "GitLab", "GL", "technologies", ["git repository monogram"]),
generic("simple-icons", "docker", "Docker", "D", "technologies", ["container monogram"]),
generic("simple-icons", "kubernetes", "Kubernetes", "K8s", "technologies", ["container cloud monogram"]),
generic("simple-icons", "react", "React", "⚛", "technologies", ["frontend framework atom symbol"]),
generic("simple-icons", "vuedotjs", "Vue", "V", "technologies", ["frontend framework monogram"]),
{
set: "simple-icons", name: "angular", label: "Angular", group: "technologies", glyph: "A",
assetSrc: angularLogo,
keywords: ["angular", "frontend", "web", "framework"],
legal: {
owner: "Google LLC / Angular",
license: "CC BY 4.0",
sourceUrl: "https://angular.dev/press-kit",
attribution: "Angular logo © Google LLC, provided under CC BY 4.0.",
productUiAllowed: true,
watermarkAllowed: true,
},
},
generic("simple-icons", "nodedotjs", "Node.js", "N", "technologies", ["javascript runtime monogram"]),
generic("simple-icons", "postgresql", "PostgreSQL", "PG", "technologies", ["database sql monogram"]),
generic("simple-icons", "redis", "Redis", "R", "technologies", ["database cache monogram"]),
generic("simple-icons", "figma", "Figma", "Fi", "technologies", ["design tool monogram"]),
restrictedBrand("simple-icons", "unity", "Unity", "technologies", GAME, ["game engine gaming framework"]),
restrictedBrand("simple-icons", "unrealengine", "Unreal Engine", "technologies", GAME, ["unreal ue game engine gaming framework"]),
];
const PLATFORM_ENTRIES: readonly ProjectIconCatalogEntry[] = [
generic("devicon", "android", "Android", "A", "platforms", ["mobile generic monogram"]),
restrictedBrand("devicon", "windows", "Windows", "platforms", TERMINAL, ["microsoft desktop operating system os"]),
restrictedBrand("devicon", "apple", "macOS / Apple", "platforms", TERMINAL, ["mac ios iphone operating system os"]),
generic("devicon", "linux", "Linux", "L", "platforms", ["operating system os monogram"]),
generic("devicon", "ubuntu", "Ubuntu", "U", "platforms", ["linux distribution distro monogram"]),
generic("devicon", "debian", "Debian", "D", "platforms", ["linux distribution distro monogram"]),
generic("devicon", "fedora", "Fedora", "F", "platforms", ["linux distribution distro monogram"]),
generic("devicon", "archlinux", "Arch Linux", "A", "platforms", ["linux distribution distro monogram"]),
generic("devicon", "redhat", "Red Hat", "RH", "platforms", ["linux distribution distro monogram"]),
generic("simple-icons", "amazonwebservices", "AWS", "AWS", "platforms", ["cloud amazon generic monogram"]),
restrictedBrand("simple-icons", "googlecloud", "Google Cloud", "platforms", GLOBE, ["google gcp cloud"]),
restrictedBrand("simple-icons", "google", "Google", "platforms", GLOBE, ["cloud web search"]),
restrictedBrand("simple-icons", "microsoft", "Microsoft", "platforms", TERMINAL, ["windows azure platform"]),
];
const SYMBOL_ENTRIES: readonly ProjectIconCatalogEntry[] = [
generic("lucide", "star", "Star", "★", "symbols", ["favorite symbol"]),
generic("lucide", "sparkles", "Sparkles", "✦", "symbols", ["magic symbol"]),
generic("lucide", "arrow-up-right", "Arrow up right", "↗", "symbols", ["direction symbol"]),
generic("lucide", "arrow-right", "Arrow right", "→", "symbols", ["direction symbol"]),
generic("lucide", "rocket", "Rocket", "▲", "symbols", ["launch space symbol"]),
generic("lucide", "code-2", "Code", "</>", "symbols", ["development brackets symbol"]),
generic("lucide", "terminal", "Terminal", ">_", "symbols", ["cli shell tool"]),
generic("lucide", "database", "Database", "DB", "symbols", ["storage server"]),
generic("lucide", "globe-2", "Globe", "◎", "symbols", ["web world symbol"]),
generic("lucide", "gamepad-2", "Game", "◆", "symbols", ["gaming controller symbol"]),
generic("idea", "idea-mark", "IdeA", "IA", "symbols", ["agent ai"]),
];
const LETTER_ENTRIES: readonly ProjectIconCatalogEntry[] = Array.from({ length: 26 }, (_, index) => {
const letter = String.fromCharCode(65 + index);
return generic("idea", `letter-${letter.toLowerCase()}`, letter, letter, "letters", ["alphabet monogram initial"]);
});
/** Product-owned, versioned catalogue. Search metadata deliberately stays English. */
export const PROJECT_ICON_CATALOG: readonly ProjectIconCatalogEntry[] = [ export const PROJECT_ICON_CATALOG: readonly ProjectIconCatalogEntry[] = [
...PLATFORM_ENTRIES, emoji("grinning-face", "Grinning face", "😀", "smileys", ["happy smile"]),
...TECHNOLOGY_MONOGRAMS, emoji("smiling-face", "Smiling face", "😊", "smileys", ["happy blush"]),
...SYMBOL_ENTRIES, emoji("cool-face", "Cool face", "😎", "smileys", ["sunglasses"]),
...LETTER_ENTRIES, emoji("thinking-face", "Thinking face", "🤔", "smileys", ["idea question"]),
emoji("nerd-face", "Nerd face", "🤓", "smileys", ["developer glasses"]),
emoji("star-struck", "Star-struck", "🤩", "smileys", ["excited"]),
emoji("robot", "Robot", "🤖", "smileys", ["ai agent bot"]),
emoji("ghost", "Ghost", "👻", "smileys", ["spooky"]),
emoji("alien", "Alien", "👽", "smileys", ["space"]),
emoji("skull", "Skull", "💀", "smileys", ["danger"]),
emoji("eyes", "Eyes", "👀", "smileys", ["watch view"]),
emoji("brain", "Brain", "🧠", "smileys", ["mind intelligence"]),
emoji("dog", "Dog", "🐶", "nature", ["animal pet"]),
emoji("cat", "Cat", "🐱", "nature", ["animal pet"]),
emoji("fox", "Fox", "🦊", "nature", ["animal"]),
emoji("lion", "Lion", "🦁", "nature", ["animal"]),
emoji("panda", "Panda", "🐼", "nature", ["animal"]),
emoji("monkey", "Monkey", "🐵", "nature", ["animal"]),
emoji("owl", "Owl", "🦉", "nature", ["animal bird night"]),
emoji("butterfly", "Butterfly", "🦋", "nature", ["animal insect"]),
emoji("snake", "Snake", "🐍", "nature", ["animal python language"]),
emoji("turtle", "Turtle", "🐢", "nature", ["animal"]),
emoji("octopus", "Octopus", "🐙", "nature", ["animal sea"]),
emoji("unicorn", "Unicorn", "🦄", "nature", ["animal magic"]),
emoji("seedling", "Seedling", "🌱", "nature", ["plant grow green"]),
emoji("evergreen-tree", "Evergreen tree", "🌲", "nature", ["plant forest"]),
emoji("cactus", "Cactus", "🌵", "nature", ["plant desert"]),
emoji("sun", "Sun", "☀️", "nature", ["weather bright"]),
emoji("moon", "Moon", "🌙", "nature", ["night"]),
emoji("cloud", "Cloud", "☁️", "nature", ["weather aws google internet"]),
emoji("lightning", "Lightning", "⚡", "nature", ["energy fast"]),
emoji("fire", "Fire", "🔥", "nature", ["hot flame"]),
emoji("apple", "Apple", "🍎", "food", ["fruit"]),
emoji("lemon", "Lemon", "🍋", "food", ["fruit"]),
emoji("watermelon", "Watermelon", "🍉", "food", ["fruit"]),
emoji("avocado", "Avocado", "🥑", "food", ["vegetable"]),
emoji("hot-pepper", "Hot pepper", "🌶️", "food", ["spicy"]),
emoji("bread", "Bread", "🍞", "food", ["bakery"]),
emoji("cheese", "Cheese", "🧀", "food", ["dairy"]),
emoji("pizza", "Pizza", "🍕", "food", ["meal"]),
emoji("hamburger", "Hamburger", "🍔", "food", ["meal"]),
emoji("sushi", "Sushi", "🍣", "food", ["meal japan"]),
emoji("coffee", "Coffee", "☕", "food", ["drink cup java"]),
emoji("beer", "Beer", "🍺", "food", ["drink"]),
emoji("cake", "Cake", "🎂", "food", ["birthday dessert"]),
emoji("soccer-ball", "Soccer ball", "⚽", "activities", ["sport football"]),
emoji("basketball", "Basketball", "🏀", "activities", ["sport"]),
emoji("trophy", "Trophy", "🏆", "activities", ["winner award"]),
emoji("medal", "Medal", "🏅", "activities", ["winner award"]),
emoji("game-controller", "Game controller", "🎮", "activities", ["gaming videogame unity unreal"]),
emoji("joystick", "Joystick", "🕹️", "activities", ["gaming arcade"]),
emoji("dice", "Dice", "🎲", "activities", ["game random"]),
emoji("puzzle", "Puzzle", "🧩", "activities", ["game plugin piece"]),
emoji("artist-palette", "Artist palette", "🎨", "activities", ["art design color figma"]),
emoji("music", "Music", "🎵", "activities", ["audio note"]),
emoji("headphones", "Headphones", "🎧", "activities", ["audio music"]),
emoji("camera", "Camera", "📷", "activities", ["photo image"]),
emoji("car", "Car", "🚗", "travel", ["vehicle road"]),
emoji("bus", "Bus", "🚌", "travel", ["vehicle transport"]),
emoji("train", "Train", "🚆", "travel", ["vehicle transport"]),
emoji("airplane", "Airplane", "✈️", "travel", ["flight vehicle"]),
emoji("rocket", "Rocket", "🚀", "travel", ["launch space fast"]),
emoji("satellite", "Satellite", "🛰️", "travel", ["space network"]),
emoji("globe", "Globe", "🌐", "travel", ["world web internet google cloud"]),
emoji("mountain", "Mountain", "⛰️", "travel", ["landscape"]),
emoji("camping", "Camping", "🏕️", "travel", ["tent outdoors"]),
emoji("city", "City", "🏙️", "travel", ["building urban"]),
emoji("house", "House", "🏠", "travel", ["home"]),
emoji("anchor", "Anchor", "⚓", "travel", ["ship docker"]),
emoji("laptop", "Laptop", "💻", "objects", ["computer code developer terminal"]),
emoji("desktop", "Desktop computer", "🖥️", "objects", ["computer monitor windows mac linux"]),
emoji("phone", "Mobile phone", "📱", "objects", ["android ios smartphone"]),
emoji("keyboard", "Keyboard", "⌨️", "objects", ["computer input"]),
emoji("floppy-disk", "Floppy disk", "💾", "objects", ["save storage database"]),
emoji("optical-disk", "Optical disk", "💿", "objects", ["storage cd"]),
emoji("battery", "Battery", "🔋", "objects", ["power energy"]),
emoji("light-bulb", "Light bulb", "💡", "objects", ["idea smart"]),
emoji("magnifying-glass", "Magnifying glass", "🔍", "objects", ["search inspect"]),
emoji("key", "Key", "🔑", "objects", ["security access"]),
emoji("lock", "Lock", "🔒", "objects", ["security private"]),
emoji("hammer-wrench", "Hammer and wrench", "🛠️", "objects", ["tools build settings"]),
emoji("gear", "Gear", "⚙️", "objects", ["settings system rust"]),
emoji("package", "Package", "📦", "objects", ["box shipping docker container"]),
emoji("folder", "Folder", "📁", "objects", ["files project directory git"]),
emoji("clipboard", "Clipboard", "📋", "objects", ["document tasks"]),
emoji("book", "Book", "📘", "objects", ["documentation read"]),
emoji("chart", "Chart", "📊", "objects", ["analytics data"]),
emoji("database", "Database", "🗄️", "objects", ["storage server sql redis"]),
emoji("red-heart", "Red heart", "❤️", "symbols", ["love favorite ruby"]),
emoji("orange-heart", "Orange heart", "🧡", "symbols", ["love favorite"]),
emoji("green-heart", "Green heart", "💚", "symbols", ["love favorite vue"]),
emoji("blue-heart", "Blue heart", "💙", "symbols", ["love favorite"]),
emoji("purple-heart", "Purple heart", "💜", "symbols", ["love favorite"]),
emoji("star", "Star", "⭐", "symbols", ["favorite"]),
emoji("sparkles", "Sparkles", "✨", "symbols", ["magic new"]),
emoji("check-mark", "Check mark", "✅", "symbols", ["success done"]),
emoji("warning", "Warning", "⚠️", "symbols", ["alert danger angular"]),
emoji("prohibited", "Prohibited", "🚫", "symbols", ["stop forbidden"]),
emoji("question-mark", "Question mark", "❓", "symbols", ["help unknown"]),
emoji("exclamation-mark", "Exclamation mark", "❗", "symbols", ["alert important"]),
emoji("recycle", "Recycle", "♻️", "symbols", ["reuse green"]),
emoji("infinity", "Infinity", "♾️", "symbols", ["continuous loop"]),
emoji("atom", "Atom", "⚛️", "symbols", ["science react"]),
emoji("up-arrow", "Up arrow", "⬆️", "symbols", ["direction"]),
emoji("right-arrow", "Right arrow", "➡️", "symbols", ["direction next"]),
]; ];
const LEGACY_EMOJI_BY_REF: Readonly<Record<string, string>> = {
"devicon:c": "laptop", "devicon:cplusplus": "laptop", "devicon:csharp": "laptop",
"devicon:java": "coffee", "devicon:javascript": "lightning", "devicon:typescript": "laptop",
"devicon:python": "snake", "devicon:rust": "gear", "devicon:go": "rocket",
"devicon:kotlin": "laptop", "devicon:swift": "rocket", "devicon:php": "globe",
"devicon:ruby": "red-heart", "devicon:android": "phone", "devicon:windows": "desktop",
"devicon:apple": "apple", "devicon:linux": "desktop", "devicon:ubuntu": "desktop",
"devicon:debian": "desktop", "devicon:fedora": "desktop", "devicon:archlinux": "desktop",
"devicon:redhat": "desktop", "simple-icons:github": "folder", "simple-icons:gitlab": "folder",
"simple-icons:docker": "package", "simple-icons:kubernetes": "anchor", "simple-icons:react": "atom",
"simple-icons:vuedotjs": "green-heart", "simple-icons:angular": "warning",
"simple-icons:nodedotjs": "seedling", "simple-icons:postgresql": "floppy-disk",
"simple-icons:redis": "database", "simple-icons:figma": "artist-palette",
"simple-icons:unity": "game-controller", "simple-icons:unrealengine": "game-controller",
"simple-icons:amazonwebservices": "cloud", "simple-icons:googlecloud": "cloud",
"simple-icons:google": "globe", "simple-icons:microsoft": "desktop",
"lucide:star": "star", "lucide:sparkles": "sparkles", "lucide:arrow-up-right": "up-arrow",
"lucide:arrow-right": "right-arrow", "lucide:rocket": "rocket", "lucide:code-2": "laptop",
"lucide:terminal": "keyboard", "lucide:database": "floppy-disk", "lucide:globe-2": "globe",
"lucide:gamepad-2": "game-controller", "idea:idea-mark": "robot",
};
export function findProjectIcon(ref: ProjectIconRef | null | undefined): ProjectIconCatalogEntry | null { export function findProjectIcon(ref: ProjectIconRef | null | undefined): ProjectIconCatalogEntry | null {
return ref ? PROJECT_ICON_CATALOG.find((entry) => entry.set === ref.set && entry.name === ref.name) ?? null : null; if (!ref || ref.set !== "emoji") return null;
return PROJECT_ICON_CATALOG.find((entry) => entry.name === ref.name) ?? null;
} }
export function resolveProjectIcon( export function resolveProjectIcon(
ref: ProjectIconRef | null | undefined, ref: ProjectIconRef | null | undefined,
surface: ProjectIconSurface = "product-ui", _surface: ProjectIconSurface = "product-ui",
): ProjectIconCatalogEntry | null { ): ProjectIconCatalogEntry | null {
let entry = findProjectIcon(ref); const direct = findProjectIcon(ref);
const visited = new Set<string>(); if (direct || !ref) return direct;
while (entry) { const emojiName = LEGACY_EMOJI_BY_REF[`${ref.set}:${ref.name}`];
const key = `${entry.set}:${entry.name}`; return emojiName ? findProjectIcon({ set: "emoji", name: emojiName }) : null;
if (visited.has(key)) return null;
visited.add(key);
const allowed = surface === "watermark" ? entry.legal.watermarkAllowed : entry.legal.productUiAllowed;
if (allowed) return entry;
entry = findProjectIcon(entry.legal.fallback);
}
return null;
} }
export function ProjectIcon({ icon: ref, size = 32, className, style, decorative = false, surface = "product-ui" }: { export function toEmojiIconRef(ref: ProjectIconRef | null | undefined): ProjectIconRef | null {
icon: ProjectIconRef; const resolved = resolveProjectIcon(ref);
return resolved ? { set: "emoji", name: resolved.name } : null;
}
function fallbackInitial(ref: ProjectIconRef | null, fallbackLabel?: string): string {
const legacyLetter = ref?.set === "idea" ? /^letter-([a-z])$/i.exec(ref.name)?.[1] : undefined;
const first = legacyLetter ?? fallbackLabel?.match(/[a-z0-9]/i)?.[0] ?? ref?.name.match(/[a-z0-9]/i)?.[0] ?? "?";
return first.toUpperCase();
}
export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, style, decorative = false, surface = "product-ui" }: {
icon: ProjectIconRef | null;
fallbackLabel?: string;
size?: number | string; size?: number | string;
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
decorative?: boolean; decorative?: boolean;
surface?: ProjectIconSurface; surface?: ProjectIconSurface;
}) { }) {
const requested = findProjectIcon(ref);
const entry = resolveProjectIcon(ref, surface); const entry = resolveProjectIcon(ref, surface);
if (!requested || !entry) return null; const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
const numericSize = typeof size === "number" ? size : null; const numericSize = typeof size === "number" ? size : null;
const isLetter = entry.group === "letters";
return ( return (
<span <span
className={className} className={className}
role={decorative ? undefined : "img"} role={decorative ? undefined : "img"}
aria-hidden={decorative || undefined} aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : requested.label} aria-label={decorative ? undefined : label}
title={decorative ? undefined : requested.label} title={decorative ? undefined : label}
data-project-icon={`${requested.set}:${requested.name}`} data-project-icon={ref ? `${ref.set}:${ref.name}` : "none"}
data-resolved-project-icon={`${entry.set}:${entry.name}`} data-resolved-project-icon={entry ? `emoji:${entry.name}` : "initial"}
style={{ style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", display: "inline-flex", alignItems: "center", justifyContent: "center",
width: size, height: size, fontSize: numericSize ? Math.max(11, numericSize * 0.38) : "34%", fontWeight: 800, width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
lineHeight: 1, letterSpacing: "-0.04em", flexShrink: 0, fontFamily: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
border: isLetter ? "1px solid currentColor" : undefined, fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
borderRadius: isLetter ? "18%" : undefined,
...style,
}} }}
> >
{entry.assetSrc {entry?.glyph ?? fallbackInitial(ref, fallbackLabel)}
? <span aria-hidden style={{ width: "78%", height: "78%", backgroundColor: "currentColor", mask: `url(${entry.assetSrc}) center / contain no-repeat`, WebkitMask: `url(${entry.assetSrc}) center / contain no-repeat` }} />
: entry.glyph}
</span> </span>
); );
} }

View File

@ -42,10 +42,13 @@ export const fr = {
"project.icon.noResults": "Aucune icône ne correspond à cette recherche.", "project.icon.noResults": "Aucune icône ne correspond à cette recherche.",
"project.icon.error": "Impossible d’appliquer l’icône.", "project.icon.error": "Impossible d’appliquer l’icône.",
"project.icon.picker": "Choisir l’icône du projet", "project.icon.picker": "Choisir l’icône du projet",
"project.icon.group.platforms": "Plateformes", "project.icon.group.smileys": "Smileys",
"project.icon.group.technologies": "Technologies / Frameworks", "project.icon.group.nature": "Animaux et nature",
"project.icon.group.symbols": "Symboles / Génériques", "project.icon.group.food": "Nourriture et boissons",
"project.icon.group.letters": "Lettres", "project.icon.group.activities": "Activités",
"project.icon.group.travel": "Voyages et lieux",
"project.icon.group.objects": "Objets",
"project.icon.group.symbols": "Symboles",
"custom.unavailable.title": "CLI custom indisponible", "custom.unavailable.title": "CLI custom indisponible",
"custom.unavailable.body": "Cette cellule reste configurée en CLI custom. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.", "custom.unavailable.body": "Cette cellule reste configurée en CLI custom. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.",
"custom.openNative": "Ouvrir temporairement la TUI native", "custom.openNative": "Ouvrir temporairement la TUI native",
@ -145,10 +148,13 @@ export const en: Record<TranslationKey, string> = {
"project.icon.noResults": "No icon matches this search.", "project.icon.noResults": "No icon matches this search.",
"project.icon.error": "Unable to apply the icon.", "project.icon.error": "Unable to apply the icon.",
"project.icon.picker": "Choose the project icon", "project.icon.picker": "Choose the project icon",
"project.icon.group.platforms": "Platforms", "project.icon.group.smileys": "Smileys",
"project.icon.group.technologies": "Technologies / Frameworks", "project.icon.group.nature": "Animals & nature",
"project.icon.group.symbols": "Symbols / Generic", "project.icon.group.food": "Food & drink",
"project.icon.group.letters": "Letters", "project.icon.group.activities": "Activities",
"project.icon.group.travel": "Travel & places",
"project.icon.group.objects": "Objects",
"project.icon.group.symbols": "Symbols",
"custom.unavailable.title": "Custom CLI unavailable", "custom.unavailable.title": "Custom CLI unavailable",
"custom.unavailable.body": "This cell remains configured for the custom CLI. Check the agent profile or temporarily use the native TUI.", "custom.unavailable.body": "This cell remains configured for the custom CLI. Check the agent profile or temporarily use the native TUI.",
"custom.openNative": "Temporarily open the native TUI", "custom.openNative": "Temporarily open the native TUI",

View File

@ -495,7 +495,7 @@ export interface UserSettingsGateway {
updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings>; updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings>;
} }
export type ProjectIconSet = "devicon" | "simple-icons" | "lucide" | "idea"; export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea";
export interface ProjectIconRef { export interface ProjectIconRef {
set: ProjectIconSet; set: ProjectIconSet;