diff --git a/frontend/src/assets/project-icons/NOTICE.md b/frontend/src/assets/project-icons/NOTICE.md new file mode 100644 index 00000000..9d6402d3 --- /dev/null +++ b/frontend/src/assets/project-icons/NOTICE.md @@ -0,0 +1,13 @@ +# 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. diff --git a/frontend/src/assets/project-icons/angular.svg b/frontend/src/assets/project-icons/angular.svg new file mode 100644 index 00000000..e3f8c217 --- /dev/null +++ b/frontend/src/assets/project-icons/angular.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 13989e42..98627a3f 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -210,6 +210,9 @@ describe("CustomAgentChatView", () => { const watermark = await screen.findByTestId("project-icon-watermark"); expect(watermark.style.pointerEvents).toBe("none"); + expect(watermark.getAttribute("aria-hidden")).toBe("true"); + expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%"); + expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%"); expect(watermark.textContent).toBe("Py"); publishProjectIconChanged({ projectId: "project-1", icon: { set: "lucide", name: "star" } }); await waitFor(() => expect(watermark.textContent).toBe("★")); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index a4ec4bd3..5a990867 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -44,7 +44,7 @@ import { markProjectCliInteractionStopped, } from "@/features/projects/projectCliInteractionActivity"; import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared"; -import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons"; +import { onProjectIconChanged, ProjectIcon, resolveProjectIcon } from "@/features/projects/projectIcons"; import type { ChatAttachmentInput, ChatDraftScope, @@ -2423,15 +2423,30 @@ export function CustomAgentChatView({ } } + const projectWatermarkRef = projectIcon && resolveProjectIcon(projectIcon, "watermark") + ? projectIcon + : null; + return (
- {projectIcon && ( -
- + {projectWatermarkRef && ( + )}
words.every((word) => entry.keywords.some((key) => key.includes(word)))); }, [query]); + const groupedResults = useMemo(() => GROUPS.map((group) => ({ + group, + entries: results.filter((entry) => entry.group === group), + })).filter(({ entries }) => entries.length > 0), [results]); return (

{t("project.icon.picker")}

setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} /> -
- {results.map((entry) => { - const active = selected?.set === entry.set && selected.name === entry.name; - return ; - })} +
+ {groupedResults.map(({ group, entries }) => ( +
+

+ {t(GROUP_LABELS[group])} +

+
+ {entries.map((entry) => { + const active = selected?.set === entry.set && selected.name === entry.name; + return ; + })} +
+
+ ))}
{results.length === 0 &&

{t("project.icon.noResults")}

}
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",