- {results.map((entry) => {
- const active = selected?.set === entry.set && selected.name === entry.name;
- return
diff --git a/frontend/src/features/projects/projectIcons.test.tsx b/frontend/src/features/projects/projectIcons.test.tsx
new file mode 100644
index 00000000..e37d273e
--- /dev/null
+++ b/frontend/src/features/projects/projectIcons.test.tsx
@@ -0,0 +1,92 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ProjectIconPicker } from "./ProjectIconPicker";
+import {
+ PROJECT_ICON_CATALOG,
+ ProjectIcon,
+ findProjectIcon,
+ resolveProjectIcon,
+} from "./projectIcons";
+
+describe("project icon catalogue", () => {
+ it("carries legal policy for every entry and an attribution for the vendored Angular artwork", () => {
+ expect(PROJECT_ICON_CATALOG.length).toBeGreaterThan(70);
+ for (const entry of PROJECT_ICON_CATALOG) {
+ expect(entry.legal.owner).toBeTruthy();
+ expect(entry.legal.license).toBeTruthy();
+ expect(typeof entry.legal.productUiAllowed).toBe("boolean");
+ expect(typeof entry.legal.watermarkAllowed).toBe("boolean");
+ }
+
+ 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([
+ ["unity", "gamepad-2"],
+ ["unrealengine", "gamepad-2"],
+ ["windows", "terminal"],
+ ["apple", "terminal"],
+ ["google", "globe-2"],
+ ["microsoft", "terminal"],
+ ])("resolves the restricted %s brand to product-owned fallback artwork", (name, fallbackName) => {
+ 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", () => {
+ const letters = PROJECT_ICON_CATALOG.filter((entry) => entry.group === "letters");
+ expect(letters.map((entry) => entry.glyph).join("")).toBe("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
+ const { getByRole } = render(
);
+ expect(getByRole("img", { name: "Z" }).style.borderTopWidth).toBe("1px");
+ expect(getByRole("img", { name: "Z" }).style.borderRadius).toBe("18%");
+ });
+});
+
+describe("ProjectIconPicker", () => {
+ function renderPicker() {
+ render(
);
+ }
+
+ it("shows the four catalogue groups", () => {
+ renderPicker();
+ expect(screen.getByText("Plateformes")).toBeTruthy();
+ expect(screen.getByText("Technologies / Frameworks")).toBeTruthy();
+ expect(screen.getByText("Symboles / Génériques")).toBeTruthy();
+ expect(screen.getByText("Lettres")).toBeTruthy();
+ });
+
+ it.each([
+ ["unity", "Unity"],
+ ["unreal", "Unreal Engine"],
+ ["angular", "Angular"],
+ ["windows", "Windows"],
+ ])("finds %s using the English search metadata", (query, label) => {
+ renderPicker();
+ fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: query } });
+ expect(screen.getByRole("option", { name: label })).toBeTruthy();
+ });
+
+ it("finds and selects an alphabet monogram", () => {
+ const onApply = vi.fn();
+ render(
);
+ fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "letter q" } });
+ fireEvent.click(screen.getByRole("option", { name: "Q" }));
+ fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
+ expect(onApply).toHaveBeenCalledWith({ set: "idea", name: "letter-q" });
+ });
+});
diff --git a/frontend/src/features/projects/projectIcons.tsx b/frontend/src/features/projects/projectIcons.tsx
index 7c794600..199dd75d 100644
--- a/frontend/src/features/projects/projectIcons.tsx
+++ b/frontend/src/features/projects/projectIcons.tsx
@@ -1,92 +1,212 @@
import type { CSSProperties } from "react";
+import angularLogo from "@/assets/project-icons/angular.svg";
import type { ProjectIconRef, ProjectIconSet } from "@/ports";
+export type ProjectIconGroup = "platforms" | "technologies" | "symbols" | "letters";
+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 {
label: string;
+ group: ProjectIconGroup;
glyph: string;
+ assetSrc?: string;
keywords: readonly string[];
+ legal: ProjectIconLegalMetadata;
}
-const icon = (set: ProjectIconSet, name: string, label: string, glyph: string, keywords: string[] = []): ProjectIconCatalogEntry => ({
- set, name, label, glyph, keywords: [label, name, ...keywords].map((word) => word.toLowerCase()),
+const OWNED: ProjectIconLegalMetadata = {
+ owner: "IdeA",
+ license: "Product-owned generic artwork",
+ productUiAllowed: true,
+ watermarkAllowed: true,
+};
+
+const generic = (
+ set: ProjectIconSet,
+ name: string,
+ label: string,
+ glyph: string,
+ group: ProjectIconGroup,
+ keywords: string[] = [],
+): ProjectIconCatalogEntry => ({
+ set, name, label, glyph, group,
+ keywords: [label, name, ...keywords].map((word) => word.toLowerCase()),
+ legal: OWNED,
+});
+
+const restrictedBrand = (
+ 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[] = [
- icon("devicon", "c", "C", "C", ["language"]),
- icon("devicon", "cplusplus", "C++", "C++", ["cpp language"]),
- icon("devicon", "csharp", "C#", "C#", ["dotnet language"]),
- icon("devicon", "java", "Java", "J", ["language jvm"]),
- icon("devicon", "javascript", "JavaScript", "JS", ["language js"]),
- icon("devicon", "typescript", "TypeScript", "TS", ["language ts"]),
- icon("devicon", "python", "Python", "Py", ["language"]),
- icon("devicon", "rust", "Rust", "Rs", ["language cargo"]),
- icon("devicon", "go", "Go", "Go", ["language golang"]),
- icon("devicon", "kotlin", "Kotlin", "Kt", ["language jvm"]),
- icon("devicon", "swift", "Swift", "Sw", ["language apple ios"]),
- icon("devicon", "php", "PHP", "PHP", ["language"]),
- icon("devicon", "ruby", "Ruby", "Rb", ["language rails"]),
- icon("devicon", "android", "Android", "A", ["platform mobile"]),
- icon("devicon", "windows", "Windows", "⊞", ["platform microsoft"]),
- icon("devicon", "apple", "macOS / Apple", "●", ["platform mac ios"]),
- icon("devicon", "linux", "Linux", "L", ["platform tux"]),
- icon("devicon", "ubuntu", "Ubuntu", "U", ["linux distribution distro"]),
- icon("devicon", "debian", "Debian", "D", ["linux distribution distro"]),
- icon("devicon", "fedora", "Fedora", "F", ["linux distribution distro"]),
- icon("devicon", "archlinux", "Arch Linux", "A", ["linux distribution distro"]),
- icon("devicon", "redhat", "Red Hat", "RH", ["linux distribution distro"]),
- icon("simple-icons", "github", "GitHub", "GH", ["git tool repository"]),
- icon("simple-icons", "gitlab", "GitLab", "GL", ["git tool repository"]),
- icon("simple-icons", "docker", "Docker", "D", ["container tool"]),
- icon("simple-icons", "kubernetes", "Kubernetes", "K8s", ["container cloud tool"]),
- icon("simple-icons", "react", "React", "⚛", ["frontend framework"]),
- icon("simple-icons", "vuedotjs", "Vue", "V", ["frontend framework"]),
- icon("simple-icons", "angular", "Angular", "A", ["frontend framework"]),
- icon("simple-icons", "nodedotjs", "Node.js", "N", ["javascript runtime"]),
- icon("simple-icons", "postgresql", "PostgreSQL", "PG", ["database sql"]),
- icon("simple-icons", "redis", "Redis", "R", ["database cache"]),
- icon("simple-icons", "amazonwebservices", "AWS", "AWS", ["cloud amazon"]),
- icon("simple-icons", "googlecloud", "Google Cloud", "GC", ["cloud gcp"]),
- icon("simple-icons", "figma", "Figma", "Fi", ["design tool"]),
- icon("lucide", "star", "Star", "★", ["favorite symbol"]),
- icon("lucide", "sparkles", "Sparkles", "✦", ["magic symbol"]),
- icon("lucide", "arrow-up-right", "Arrow up right", "↗", ["direction symbol"]),
- icon("lucide", "arrow-right", "Arrow right", "→", ["direction symbol"]),
- icon("lucide", "rocket", "Rocket", "▲", ["launch space symbol"]),
- icon("lucide", "code-2", "Code", ">", ["development brackets symbol"]),
- icon("lucide", "terminal", "Terminal", ">_", ["cli shell tool"]),
- icon("lucide", "database", "Database", "DB", ["storage server"]),
- icon("lucide", "globe-2", "Globe", "◎", ["web world symbol"]),
- icon("lucide", "gamepad-2", "Game", "◆", ["gaming symbol"]),
- icon("idea", "idea-mark", "IdeA", "IA", ["agent ai"]),
+ ...PLATFORM_ENTRIES,
+ ...TECHNOLOGY_MONOGRAMS,
+ ...SYMBOL_ENTRIES,
+ ...LETTER_ENTRIES,
];
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;
}
-export function ProjectIcon({ icon: ref, size = 32, className, style, decorative = false }: {
+export function resolveProjectIcon(
+ ref: ProjectIconRef | null | undefined,
+ surface: ProjectIconSurface = "product-ui",
+): ProjectIconCatalogEntry | null {
+ let entry = findProjectIcon(ref);
+ const visited = new Set
();
+ while (entry) {
+ const key = `${entry.set}:${entry.name}`;
+ 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" }: {
icon: ProjectIconRef;
- size?: number;
+ size?: number | string;
className?: string;
style?: CSSProperties;
decorative?: boolean;
+ surface?: ProjectIconSurface;
}) {
- const entry = findProjectIcon(ref);
- if (!entry) return null;
+ const requested = findProjectIcon(ref);
+ const entry = resolveProjectIcon(ref, surface);
+ if (!requested || !entry) return null;
+ const numericSize = typeof size === "number" ? size : null;
+ const isLetter = entry.group === "letters";
return (
{entry.glyph}
+ >
+ {entry.assetSrc
+ ?
+ : entry.glyph}
+
);
}
diff --git a/frontend/src/i18n/catalog.ts b/frontend/src/i18n/catalog.ts
index 57a2eebd..14a89c8b 100644
--- a/frontend/src/i18n/catalog.ts
+++ b/frontend/src/i18n/catalog.ts
@@ -42,6 +42,10 @@ export const fr = {
"project.icon.noResults": "Aucune icône ne correspond à cette recherche.",
"project.icon.error": "Impossible d’appliquer l’icône.",
"project.icon.picker": "Choisir l’icône du projet",
+ "project.icon.group.platforms": "Plateformes",
+ "project.icon.group.technologies": "Technologies / Frameworks",
+ "project.icon.group.symbols": "Symboles / Génériques",
+ "project.icon.group.letters": "Lettres",
"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.openNative": "Ouvrir temporairement la TUI native",
@@ -141,6 +145,10 @@ export const en: Record = {
"project.icon.noResults": "No icon matches this search.",
"project.icon.error": "Unable to apply the icon.",
"project.icon.picker": "Choose the project icon",
+ "project.icon.group.platforms": "Platforms",
+ "project.icon.group.technologies": "Technologies / Frameworks",
+ "project.icon.group.symbols": "Symbols / Generic",
+ "project.icon.group.letters": "Letters",
"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.openNative": "Temporarily open the native TUI",