merge(frontend): feature #335 — registre icônes de marque légales, monogrammes A-Z et filigrane CLI agrandi (QA verte)

This commit is contained in:
2026-09-12 12:29:43 +02:00
8 changed files with 356 additions and 70 deletions

View File

@ -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.

View File

@ -0,0 +1,10 @@
<?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>

After

Width:  |  Height:  |  Size: 512 B

View File

@ -210,6 +210,9 @@ describe("CustomAgentChatView", () => {
const watermark = await screen.findByTestId("project-icon-watermark"); const watermark = await screen.findByTestId("project-icon-watermark");
expect(watermark.style.pointerEvents).toBe("none"); 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"); expect(watermark.textContent).toBe("Py");
publishProjectIconChanged({ projectId: "project-1", icon: { set: "lucide", name: "star" } }); publishProjectIconChanged({ projectId: "project-1", icon: { set: "lucide", name: "star" } });
await waitFor(() => expect(watermark.textContent).toBe("★")); await waitFor(() => expect(watermark.textContent).toBe("★"));

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 } from "@/features/projects/projectIcons"; import { onProjectIconChanged, ProjectIcon, resolveProjectIcon } from "@/features/projects/projectIcons";
import type { import type {
ChatAttachmentInput, ChatAttachmentInput,
ChatDraftScope, ChatDraftScope,
@ -2423,15 +2423,30 @@ export function CustomAgentChatView({
} }
} }
const projectWatermarkRef = projectIcon && resolveProjectIcon(projectIcon, "watermark")
? projectIcon
: null;
return ( return (
<div <div
ref={chatRootRef} ref={chatRootRef}
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"
> >
{projectIcon && ( {projectWatermarkRef && (
<div data-testid="project-icon-watermark" aria-hidden className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]" style={{ pointerEvents: "none" }}> <div
<ProjectIcon icon={projectIcon} size={260} decorative /> data-testid="project-icon-watermark"
aria-hidden="true"
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" }}
>
<ProjectIcon
icon={projectWatermarkRef}
surface="watermark"
size="92%"
decorative
style={{ fontSize: "min(34cqw, 34cqh)", maxWidth: "92cqh", maxHeight: "92cqw" }}
/>
</div> </div>
)} )}
<div <div

View File

@ -2,7 +2,19 @@ import { useMemo, useState } from "react";
import type { ProjectIconRef } from "@/ports"; import type { ProjectIconRef } from "@/ports";
import { Button, Input } from "@/shared"; import { Button, Input } from "@/shared";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { PROJECT_ICON_CATALOG, ProjectIcon } from "./projectIcons"; import {
PROJECT_ICON_CATALOG,
ProjectIcon,
type ProjectIconGroup,
} from "./projectIcons";
const GROUPS: readonly ProjectIconGroup[] = ["platforms", "technologies", "symbols", "letters"];
const GROUP_LABELS = {
platforms: "project.icon.group.platforms",
technologies: "project.icon.group.technologies",
symbols: "project.icon.group.symbols",
letters: "project.icon.group.letters",
} as const;
export function ProjectIconPicker({ value, onApply, onCancel }: { export function ProjectIconPicker({ value, onApply, onCancel }: {
value: ProjectIconRef | null; value: ProjectIconRef | null;
@ -16,14 +28,24 @@ export function ProjectIconPicker({ value, onApply, onCancel }: {
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))));
}, [query]); }, [query]);
const groupedResults = useMemo(() => GROUPS.map((group) => ({
group,
entries: results.filter((entry) => entry.group === group),
})).filter(({ entries }) => entries.length > 0), [results]);
return ( return (
<div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4"> <div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4">
<div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl"> <div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl">
<h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3> <h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3>
<Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} /> <Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />
<div role="listbox" aria-label={t("project.icon.results")} className="grid min-h-28 grid-cols-4 gap-2 overflow-y-auto sm:grid-cols-6"> <div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-4 overflow-y-auto pr-1">
{results.map((entry) => { {groupedResults.map(({ group, entries }) => (
<div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h4 id={`project-icon-group-${group}`} className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">
{t(GROUP_LABELS[group])}
</h4>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name; const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}> return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative /> <ProjectIcon icon={entry} size={32} decorative />
@ -31,6 +53,9 @@ export function ProjectIconPicker({ value, onApply, onCancel }: {
</button>; </button>;
})} })}
</div> </div>
</div>
))}
</div>
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>} {results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
<div className="flex flex-wrap justify-between gap-2"> <div className="flex flex-wrap justify-between gap-2">
<Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button> <Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button>

View File

@ -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(<ProjectIcon icon={{ set: "idea", name: "letter-z" }} />);
expect(getByRole("img", { name: "Z" }).style.borderTopWidth).toBe("1px");
expect(getByRole("img", { name: "Z" }).style.borderRadius).toBe("18%");
});
});
describe("ProjectIconPicker", () => {
function renderPicker() {
render(<ProjectIconPicker value={null} onApply={vi.fn()} onCancel={vi.fn()} />);
}
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(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
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" });
});
});

View File

@ -1,92 +1,212 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import angularLogo from "@/assets/project-icons/angular.svg";
import type { ProjectIconRef, ProjectIconSet } from "@/ports"; 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 { export interface ProjectIconCatalogEntry extends ProjectIconRef {
label: string; label: string;
group: ProjectIconGroup;
glyph: string; glyph: string;
assetSrc?: string;
keywords: readonly string[]; keywords: readonly string[];
legal: ProjectIconLegalMetadata;
} }
const icon = (set: ProjectIconSet, name: string, label: string, glyph: string, keywords: string[] = []): ProjectIconCatalogEntry => ({ const OWNED: ProjectIconLegalMetadata = {
set, name, label, glyph, keywords: [label, name, ...keywords].map((word) => word.toLowerCase()), 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. */ /** Product-owned, versioned catalogue. Search metadata deliberately stays English. */
export const PROJECT_ICON_CATALOG: readonly ProjectIconCatalogEntry[] = [ export const PROJECT_ICON_CATALOG: readonly ProjectIconCatalogEntry[] = [
icon("devicon", "c", "C", "C", ["language"]), ...PLATFORM_ENTRIES,
icon("devicon", "cplusplus", "C++", "C++", ["cpp language"]), ...TECHNOLOGY_MONOGRAMS,
icon("devicon", "csharp", "C#", "C#", ["dotnet language"]), ...SYMBOL_ENTRIES,
icon("devicon", "java", "Java", "J", ["language jvm"]), ...LETTER_ENTRIES,
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"]),
]; ];
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; 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<string>();
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; icon: ProjectIconRef;
size?: number; size?: number | string;
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
decorative?: boolean; decorative?: boolean;
surface?: ProjectIconSurface;
}) { }) {
const entry = findProjectIcon(ref); const requested = findProjectIcon(ref);
if (!entry) return null; const entry = resolveProjectIcon(ref, surface);
if (!requested || !entry) return 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 : entry.label} aria-label={decorative ? undefined : requested.label}
title={decorative ? undefined : entry.label} title={decorative ? undefined : requested.label}
data-project-icon={`${requested.set}:${requested.name}`}
data-resolved-project-icon={`${entry.set}:${entry.name}`}
style={{ style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", display: "inline-flex", alignItems: "center", justifyContent: "center",
width: size, height: size, fontSize: Math.max(11, size * 0.38), fontWeight: 800, width: size, height: size, fontSize: numericSize ? Math.max(11, numericSize * 0.38) : "34%", fontWeight: 800,
lineHeight: 1, letterSpacing: "-0.04em", ...style, lineHeight: 1, letterSpacing: "-0.04em", flexShrink: 0,
border: isLetter ? "1px solid currentColor" : undefined,
borderRadius: isLetter ? "18%" : undefined,
...style,
}} }}
>{entry.glyph}</span> >
{entry.assetSrc
? <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>
); );
} }

View File

@ -42,6 +42,10 @@ 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.technologies": "Technologies / Frameworks",
"project.icon.group.symbols": "Symboles / Génériques",
"project.icon.group.letters": "Lettres",
"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",
@ -141,6 +145,10 @@ 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.technologies": "Technologies / Frameworks",
"project.icon.group.symbols": "Symbols / Generic",
"project.icon.group.letters": "Letters",
"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",