From ff1d9af53a66e43e35fde52a2df121a987b0fa16 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 13 Sep 2026 10:52:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(domain,frontend):=20remplace=20le=20catalo?= =?UTF-8?q?gue=20d'ic=C3=B4nes=20projet=20par=20des=20emojis=20Unicode=20s?= =?UTF-8?q?yst=C3=A8me=20=E2=80=94=20#345=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/domain/src/settings.rs | 2 + crates/domain/tests/settings.rs | 14 + frontend/src/adapters/project.test.ts | 4 +- frontend/src/assets/project-icons/NOTICE.md | 13 - frontend/src/assets/project-icons/angular.svg | 10 - .../agents/CustomAgentChatView.test.tsx | 6 +- .../features/agents/CustomAgentChatView.tsx | 37 +- .../features/projects/ProjectIconPicker.tsx | 14 +- .../projects/ProjectSettingsPanel.test.tsx | 8 +- .../projects/ProjectSettingsPanel.tsx | 11 +- .../features/projects/projectIcons.test.tsx | 101 +++--- .../src/features/projects/projectIcons.tsx | 340 +++++++++--------- frontend/src/i18n/catalog.ts | 22 +- frontend/src/ports/index.ts | 2 +- 14 files changed, 304 insertions(+), 280 deletions(-) delete mode 100644 frontend/src/assets/project-icons/NOTICE.md delete mode 100644 frontend/src/assets/project-icons/angular.svg diff --git a/crates/domain/src/settings.rs b/crates/domain/src/settings.rs index 2dbbaecb..901a9450 100644 --- a/crates/domain/src/settings.rs +++ b/crates/domain/src/settings.rs @@ -38,6 +38,8 @@ pub struct IdeaUserSettings { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ProjectIconSet { + /// Unicode emoji catalogue. + Emoji, /// Devicon language and platform catalogue. Devicon, /// Simple Icons brand catalogue. diff --git a/crates/domain/tests/settings.rs b/crates/domain/tests/settings.rs index 22938408..01a24132 100644 --- a/crates/domain/tests/settings.rs +++ b/crates/domain/tests/settings.rs @@ -45,6 +45,7 @@ fn language_roundtrips_as_stable_codes_and_rejects_unknown_values() { #[test] fn project_icon_roundtrips_for_every_known_set() { for (set, wire) in [ + (ProjectIconSet::Emoji, "emoji"), (ProjectIconSet::Devicon, "devicon"), (ProjectIconSet::SimpleIcons, "simple-icons"), (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] fn project_icon_rejects_unknown_sets_and_unstable_or_oversized_names() { assert!( diff --git a/frontend/src/adapters/project.test.ts b/frontend/src/adapters/project.test.ts index 6d4f12b3..27500e60 100644 --- a/frontend/src/adapters/project.test.ts +++ b/frontend/src/adapters/project.test.ts @@ -67,12 +67,12 @@ describe("TauriProjectGateway invoke payloads", () => { it("passes project icon updates through the additive settings request", async () => { const gateway = new TauriProjectGateway(); await gateway.updateProjectSettings("proj-1", { - icon: { set: "devicon", name: "typescript" }, + icon: { set: "emoji", name: "rocket" }, }); expect(invoke).toHaveBeenCalledWith("update_project_settings", { request: { projectId: "proj-1", - icon: { set: "devicon", name: "typescript" }, + icon: { set: "emoji", name: "rocket" }, }, }); }); diff --git a/frontend/src/assets/project-icons/NOTICE.md b/frontend/src/assets/project-icons/NOTICE.md deleted file mode 100644 index 9d6402d3..00000000 --- a/frontend/src/assets/project-icons/NOTICE.md +++ /dev/null @@ -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. diff --git a/frontend/src/assets/project-icons/angular.svg b/frontend/src/assets/project-icons/angular.svg deleted file mode 100644 index e3f8c217..00000000 --- a/frontend/src/assets/project-icons/angular.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 968a6bfb..0d66e639 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -213,9 +213,9 @@ describe("CustomAgentChatView", () => { 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("★")); + expect(watermark.textContent).toBe("🐍"); + publishProjectIconChanged({ projectId: "project-1", icon: { set: "emoji", name: "star" } }); + await waitFor(() => expect(watermark.textContent).toBe("⭐")); }); it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index ce2f6472..4ba398f7 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, resolveProjectIcon } from "@/features/projects/projectIcons"; +import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons"; import type { ChatAttachmentInput, ChatDraftScope, @@ -2478,9 +2478,7 @@ export function CustomAgentChatView({ } } - const projectWatermarkRef = projectIcon && resolveProjectIcon(projectIcon, "watermark") - ? projectIcon - : null; + const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId; return (
- {projectWatermarkRef && ( - - )} +
(value); + const [selected, setSelected] = useState(() => toEmojiIconRef(value)); const results = useMemo(() => { 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)))); diff --git a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx index fd940b0c..f997a235 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx @@ -278,14 +278,14 @@ describe("ProjectSettingsPanel", () => { fireEvent.click(await screen.findByRole("button", { name: "Choisir une icône" })); const search = screen.getByLabelText("Search icons in English"); fireEvent.change(search, { target: { value: "docker" } }); - const docker = await screen.findByRole("option", { name: "Docker" }); - fireEvent.click(docker); - expect(docker.getAttribute("aria-selected")).toBe("true"); + const packageEmoji = await screen.findByRole("option", { name: "Package" }); + fireEvent.click(packageEmoji); + expect(packageEmoji.getAttribute("aria-selected")).toBe("true"); fireEvent.click(screen.getByRole("button", { name: "Appliquer" })); await waitFor(() => 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(); diff --git a/frontend/src/features/projects/ProjectSettingsPanel.tsx b/frontend/src/features/projects/ProjectSettingsPanel.tsx index 9edce4e0..8cffabeb 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.tsx @@ -22,6 +22,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { const { t } = useI18n(); const [themeColor, setThemeColor] = useState(null); const [projectIcon, setProjectIcon] = useState(null); + const [projectName, setProjectName] = useState(""); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [busy, setBusy] = useState(true); const [error, setError] = useState(null); @@ -74,6 +75,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { }; }, [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( () => onProjectThemeChanged( @@ -188,7 +197,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
- {projectIcon ? : —} + {t("project.icon")}
diff --git a/frontend/src/features/projects/projectIcons.test.tsx b/frontend/src/features/projects/projectIcons.test.tsx index e37d273e..7b3b09b9 100644 --- a/frontend/src/features/projects/projectIcons.test.tsx +++ b/frontend/src/features/projects/projectIcons.test.tsx @@ -7,86 +7,81 @@ import { ProjectIcon, findProjectIcon, resolveProjectIcon, + toEmojiIconRef, } 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); +describe("project emoji catalogue", () => { + it("contains only stable Unicode emoji references and no bundled artwork metadata", () => { + expect(PROJECT_ICON_CATALOG.length).toBeGreaterThan(90); 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"); + expect(entry.set).toBe("emoji"); + expect(entry.name).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + expect(entry.glyph).toBeTruthy(); + 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([ - ["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("resolves known legacy references to emoji and unknown references to an initial", () => { + expect(resolveProjectIcon({ set: "devicon", name: "python" })?.glyph).toBe("🐍"); + expect(toEmojiIconRef({ set: "lucide", name: "star" })).toEqual({ set: "emoji", name: "star" }); + expect(findProjectIcon({ set: "devicon", name: "python" })).toBeNull(); + + const { getByRole } = render(); + expect(getByRole("img", { name: "U" }).textContent).toBe("U"); + expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial"); }); - 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"); + it("keeps legacy alphabet initials readable", () => { const { getByRole } = render(); - expect(getByRole("img", { name: "Z" }).style.borderTopWidth).toBe("1px"); - expect(getByRole("img", { name: "Z" }).style.borderRadius).toBe("18%"); + expect(getByRole("img", { name: "Z" }).textContent).toBe("Z"); + }); + + it("uses the project initial when no icon is configured", () => { + const { getByRole } = render(); + expect(getByRole("img", { name: "A" }).textContent).toBe("A"); + expect(getByRole("img").dataset.projectIcon).toBe("none"); }); }); describe("ProjectIconPicker", () => { - function renderPicker() { - render(); + function renderPicker(value = null as Parameters[0]["value"]) { + render(); } - it("shows the four catalogue groups", () => { + it("shows phone-like emoji categories only", () => { 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(); + for (const label of ["Smileys", "Animaux et nature", "Nourriture et boissons", "Activités", "Voyages et lieux", "Objets", "Symboles"]) { + expect(screen.getByText(label)).toBeTruthy(); + } + expect(screen.queryByText("Technologies / Frameworks")).toBeNull(); + expect(screen.queryByText("Lettres")).toBeNull(); }); it.each([ - ["unity", "Unity"], - ["unreal", "Unreal Engine"], - ["angular", "Angular"], - ["windows", "Windows"], + ["python", "Snake"], + ["docker", "Package"], + ["space launch", "Rocket"], + ["database", "Floppy disk"], ])("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", () => { + it("selects and persists an emoji reference", () => { 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.change(screen.getByLabelText("Search icons in English"), { target: { value: "rocket" } }); + fireEvent.click(screen.getByRole("option", { name: "Rocket" })); 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(); + fireEvent.click(screen.getByRole("button", { name: "Appliquer" })); + expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "snake" }); }); }); diff --git a/frontend/src/features/projects/projectIcons.tsx b/frontend/src/features/projects/projectIcons.tsx index 199dd75d..d847320c 100644 --- a/frontend/src/features/projects/projectIcons.tsx +++ b/frontend/src/features/projects/projectIcons.tsx @@ -1,211 +1,231 @@ import type { CSSProperties } from "react"; -import angularLogo from "@/assets/project-icons/angular.svg"; -import type { ProjectIconRef, ProjectIconSet } from "@/ports"; +import type { ProjectIconRef } 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 interface ProjectIconLegalMetadata { - owner: string; - license: string; - sourceUrl?: string; - attribution?: string; - productUiAllowed: boolean; - watermarkAllowed: boolean; - fallback?: ProjectIconRef; -} - export interface ProjectIconCatalogEntry extends ProjectIconRef { + set: "emoji"; label: string; group: ProjectIconGroup; glyph: string; - assetSrc?: string; keywords: readonly string[]; - legal: ProjectIconLegalMetadata; } -const OWNED: ProjectIconLegalMetadata = { - owner: "IdeA", - license: "Product-owned generic artwork", - productUiAllowed: true, - watermarkAllowed: true, -}; - -const generic = ( - set: ProjectIconSet, +const emoji = ( name: string, label: string, glyph: string, group: ProjectIconGroup, keywords: string[] = [], ): ProjectIconCatalogEntry => ({ - set, name, label, glyph, group, + set: "emoji", + 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. */ +/** Curated Unicode characters only. Their artwork is supplied by the operating system. */ export const PROJECT_ICON_CATALOG: readonly ProjectIconCatalogEntry[] = [ - ...PLATFORM_ENTRIES, - ...TECHNOLOGY_MONOGRAMS, - ...SYMBOL_ENTRIES, - ...LETTER_ENTRIES, + emoji("grinning-face", "Grinning face", "😀", "smileys", ["happy smile"]), + emoji("smiling-face", "Smiling face", "😊", "smileys", ["happy blush"]), + emoji("cool-face", "Cool face", "😎", "smileys", ["sunglasses"]), + 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> = { + "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 { - 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( ref: ProjectIconRef | null | undefined, - surface: ProjectIconSurface = "product-ui", + _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; + const direct = findProjectIcon(ref); + if (direct || !ref) return direct; + const emojiName = LEGACY_EMOJI_BY_REF[`${ref.set}:${ref.name}`]; + return emojiName ? findProjectIcon({ set: "emoji", name: emojiName }) : null; } -export function ProjectIcon({ icon: ref, size = 32, className, style, decorative = false, surface = "product-ui" }: { - icon: ProjectIconRef; +export function toEmojiIconRef(ref: ProjectIconRef | null | undefined): ProjectIconRef | null { + 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; className?: string; style?: CSSProperties; decorative?: boolean; surface?: ProjectIconSurface; }) { - const requested = findProjectIcon(ref); 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 isLetter = entry.group === "letters"; return ( - {entry.assetSrc - ? - : entry.glyph} + {entry?.glyph ?? fallbackInitial(ref, fallbackLabel)} ); } diff --git a/frontend/src/i18n/catalog.ts b/frontend/src/i18n/catalog.ts index 14a89c8b..2fa14464 100644 --- a/frontend/src/i18n/catalog.ts +++ b/frontend/src/i18n/catalog.ts @@ -42,10 +42,13 @@ 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", + "project.icon.group.smileys": "Smileys", + "project.icon.group.nature": "Animaux et nature", + "project.icon.group.food": "Nourriture et boissons", + "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.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", @@ -145,10 +148,13 @@ 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", + "project.icon.group.smileys": "Smileys", + "project.icon.group.nature": "Animals & nature", + "project.icon.group.food": "Food & drink", + "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.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", diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 4b8d1631..d736a6ad 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -495,7 +495,7 @@ export interface UserSettingsGateway { updateUserSettings(settings: Partial): Promise; } -export type ProjectIconSet = "devicon" | "simple-icons" | "lucide" | "idea"; +export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea"; export interface ProjectIconRef { set: ProjectIconSet;