authorgravatar for 32248437+ShpetimA@users.noreply.github.comShpetim <32248437+ShpetimA@users.noreply.github.com> 2026-09-14 20:58:19+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 15:58:19-03:00
log112a7088da63ea4fa48fee63dc8f29557c545ed7
tree895f5f040d9d9778f45f7738aa4f78837c5a75e3
parentf328db30da063a7bce9a9d038fc583d3ff83673a
signature Signed by PGP key B5690EEEBB952194

fix(web): use project monograms for automatic icon fallbacks (#11572)

Co-authored-by: maria-rcks <maria@kuuro.net>

7 files changed, 150 insertions(+), 347 deletions(-)

apps/web/src/components/ProjectFavicon.test.tsx+6-14
......@@ -122,36 +122,28 @@ describe("ProjectFavicon", () => {
122122 testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg";
123123 });
124124
125 it("shows a project-name icon when no favicon exists", () => {
125 it("shows the project monogram when no favicon exists", () => {
126126 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
127127
128128 const element = ProjectFavicon({
129129 project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }),
130130 }) as ReactElement<{
131 readonly colorClassName?: string;
132 readonly emoji?: string;
133 readonly icon?: ComponentType<{ className?: string }>;
131 readonly projectName?: string;
134132 }>;
135133
136 expect(element.props.icon).toBeDefined();
137 expect(element.props.emoji).toBeUndefined();
138 expect(element.props.colorClassName).toContain("text-cyan-600");
134 expect(element.props.projectName).toBe("analytics-db");
139135 });
140136
141 it("chooses a deterministic semantic icon", () => {
137 it("uses the same monogram fallback for every project category", () => {
142138 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
143139
144140 const element = ProjectFavicon({
145141 project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }),
146142 }) as ReactElement<{
147 readonly colorClassName?: string;
148 readonly emoji?: string;
149 readonly icon?: ComponentType<{ className?: string }>;
143 readonly projectName?: string;
150144 }>;
151145
152 expect(element.props.icon).toBeDefined();
153 expect(element.props.emoji).toBeUndefined();
154 expect(element.props.colorClassName).toContain("text-violet-600");
146 expect(element.props.projectName).toBe("agent-runtime");
155147 });
156148
157149 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {
apps/web/src/components/ProjectFavicon.tsx+63-105
......@@ -1,39 +1,15 @@
1import type { ProjectIconColor } from "@t3tools/contracts";
21import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
32import {
43 getProjectFaviconResourceKey,
54 isProjectFaviconFallbackUrl,
65} from "@t3tools/shared/projectFavicon";
7import {
8 BotIcon,
9 BookOpenIcon,
10 BracesIcon,
11 CircuitBoardIcon,
12 CloudCogIcon,
13 Code2Icon,
14 DatabaseIcon,
15 FlaskConicalIcon,
16 FolderCodeIcon,
17 Gamepad2Icon,
18 Globe2Icon,
19 ImageIcon,
20 Layers3Icon,
21 MonitorIcon,
22 MusicIcon,
23 PackageIcon,
24 ServerIcon,
25 ShieldCheckIcon,
26 ShoppingBagIcon,
27 SmartphoneIcon,
28 TerminalIcon,
29 VideoIcon,
30} from "lucide-react";
6import { FolderCodeIcon } from "lucide-react";
317import type { IconName } from "lucide-react/dynamic";
328import type { ComponentType } from "react";
339import { lazy, Suspense, useState } from "react";
3410import { useAtomValue } from "@effect/atom-react";
3511import { projectFaviconUrlAtom } from "../state/assets";
36import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
12import { deriveProjectIdentity } from "../projectIdentity";
3713import { projectIconColorClassName } from "../projectIconColors";
3814import { cn } from "~/lib/utils";
3915
......@@ -45,56 +21,6 @@ function DynamicProjectIconFallback() {
4521 return <FolderCodeIcon className="size-full text-[inherit]" />;
4622}
4723
48const PROJECT_ICONS: Record<ProjectIconName, ComponentType<{ className?: string }>> = {
49 ai: BotIcon,
50 book: BookOpenIcon,
51 braces: BracesIcon,
52 circuit: CircuitBoardIcon,
53 cloud: CloudCogIcon,
54 code: Code2Icon,
55 database: DatabaseIcon,
56 desktop: MonitorIcon,
57 "folder-code": FolderCodeIcon,
58 game: Gamepad2Icon,
59 image: ImageIcon,
60 layers: Layers3Icon,
61 mobile: SmartphoneIcon,
62 music: MusicIcon,
63 package: PackageIcon,
64 security: ShieldCheckIcon,
65 server: ServerIcon,
66 shopping: ShoppingBagIcon,
67 terminal: TerminalIcon,
68 test: FlaskConicalIcon,
69 video: VideoIcon,
70 web: Globe2Icon,
71};
72
73const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = {
74 ai: "violet",
75 book: "amber",
76 braces: "purple",
77 circuit: "teal",
78 cloud: "sky",
79 code: "blue",
80 database: "cyan",
81 desktop: "indigo",
82 "folder-code": "orange",
83 game: "emerald",
84 image: "pink",
85 layers: "fuchsia",
86 mobile: "lime",
87 music: "fuchsia",
88 package: "orange",
89 security: "teal",
90 server: "blue",
91 shopping: "rose",
92 terminal: "green",
93 test: "yellow",
94 video: "red",
95 web: "sky",
96};
97
9824// The slice of a project that decides its icon. Every surface must pass the
9925// project record itself (or a snapshot spread from it) so the saved title, favicon
10026// and icon override always travel together. Passing a display label as the title
......@@ -103,7 +29,6 @@ export type ProjectFaviconProject = Pick<
10329 EnvironmentProject,
10430 "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon"
10531>;
106
10732export function ProjectFavicon(input: {
10833 project: ProjectFaviconProject;
10934 className?: string | undefined;
......@@ -118,7 +43,13 @@ export function ProjectFavicon(input: {
11843 }),
11944 );
12045 if (project.projectIcon?.kind === "emoji") {
121 return <ProjectFaviconFallback className={input.className} emoji={project.projectIcon.emoji} />;
46 return (
47 <ProjectFaviconFallback
48 className={input.className}
49 icon={FolderCodeIcon}
50 emoji={project.projectIcon.emoji}
51 />
52 );
12253 }
12354 if (project.projectIcon?.kind === "lucide") {
12455 const colorClassName = projectIconColorClassName(project.projectIcon.color);
......@@ -139,25 +70,14 @@ export function ProjectFavicon(input: {
13970 </span>
14071 );
14172 }
142 const automaticIconName = input.fallbackIcon
143 ? null
144 : selectProjectIcon(project.title, project.workspaceRoot);
145 const FallbackIcon =
146 input.fallbackIcon ??
147 (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined);
148 const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined;
149 const fallbackColorClassName =
150 automaticIconName?.kind === "lucide"
151 ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon])
152 : undefined;
73 const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon;
15374
15475 if (!src || isProjectFaviconFallbackUrl(src)) {
15576 return (
15677 <ProjectFaviconFallback
15778 className={input.className}
158 colorClassName={fallbackColorClassName}
15979 icon={FallbackIcon}
160 emoji={fallbackEmoji}
80 projectName={project.title}
16181 />
16282 );
16383 }
......@@ -174,23 +94,65 @@ export function ProjectFavicon(input: {
17494 src={src}
17595 className={input.className}
17696 fallbackIcon={FallbackIcon}
177 fallbackEmoji={fallbackEmoji}
178 fallbackColorClassName={fallbackColorClassName}
97 fallbackProjectName={project.title}
17998 />
18099 );
181100}
182101
183102function ProjectFaviconFallback({
184103 className,
185 colorClassName,
186104 icon: Icon,
187105 emoji,
106 projectName,
188107}: {
189108 readonly className?: string | undefined;
190 readonly colorClassName?: string | undefined;
191 readonly icon?: ComponentType<{ className?: string }> | undefined;
109 readonly icon: ComponentType<{ className?: string }>;
192110 readonly emoji?: string | undefined;
111 readonly projectName?: string | undefined;
193112}) {
113 if (projectName && projectName.trim().length > 0) {
114 const identity = deriveProjectIdentity(projectName);
115 return (
116 <svg
117 aria-hidden="true"
118 viewBox="0 0 16 16"
119 className={cn(
120 "size-4 shrink-0 overflow-hidden rounded-[25%] font-mono select-none",
121 className,
122 )}
123 style={{
124 backgroundColor: identity.background,
125 backgroundImage: `linear-gradient(145deg, ${identity.highlight}, ${identity.background} 72%)`,
126 }}
127 >
128 <text
129 x="8"
130 y="10.8"
131 textAnchor="middle"
132 fill="white"
133 className="font-mono"
134 fontSize="8.25"
135 fontWeight="700"
136 textLength="12"
137 lengthAdjust="spacingAndGlyphs"
138 textRendering="geometricPrecision"
139 >
140 {identity.monogram}
141 </text>
142 <rect
143 x="0.25"
144 y="0.25"
145 width="15.5"
146 height="15.5"
147 rx="3.75"
148 fill="none"
149 strokeWidth="0.5"
150 className="stroke-black/10 dark:stroke-white/10"
151 />
152 </svg>
153 );
154 }
155
194156 if (emoji) {
195157 return (
196158 <span
......@@ -205,22 +167,19 @@ function ProjectFaviconFallback({
205167 );
206168 }
207169
208 if (!Icon) return null;
209 return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", colorClassName, className)} />;
170 return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", className)} />;
210171}
211172
212173function ProjectFaviconImage({
213174 src,
214175 className,
215176 fallbackIcon: FallbackIcon,
216 fallbackEmoji,
217 fallbackColorClassName,
177 fallbackProjectName,
218178}: {
219179 readonly src: string;
220180 readonly className?: string | undefined;
221 readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined;
222 readonly fallbackEmoji?: string | undefined;
223 readonly fallbackColorClassName?: string | undefined;
181 readonly fallbackIcon: ComponentType<{ className?: string }>;
182 readonly fallbackProjectName?: string | undefined;
224183}) {
225184 const [displayedSrc, setDisplayedSrc] = useState<string | null>(() =>
226185 src.startsWith("data:image/") ? src : null,
......@@ -235,9 +194,8 @@ function ProjectFaviconImage({
235194 {displayedSrc === null ? (
236195 <ProjectFaviconFallback
237196 className={className}
238 colorClassName={fallbackColorClassName}
239197 icon={FallbackIcon}
240 emoji={fallbackEmoji}
198 projectName={fallbackProjectName}
241199 />
242200 ) : null}
243201 {displayedSrc ? (
apps/web/src/projectIconModel.test.ts deleted-36
......@@ -1,36 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2import { PROJECT_ICON_NAMES, selectProjectIcon } from "./projectIconModel";
3
4describe("selectProjectIcon", () => {
5 it.each([
6 ["customer-api", "server"],
7 ["AnalyticsDatabase", "database"],
8 ["ios-client", "mobile"],
9 ["terraform-infra", "cloud"],
10 ["developer-docs", "book"],
11 ["shop-frontend", "shopping"],
12 ["agent-runtime", "ai"],
13 ["video-studio", "video"],
14 ] as const)("classifies %s as %s", (projectName, expectedIcon) => {
15 expect(selectProjectIcon(projectName, `/workspace/${projectName}`).icon).toBe(expectedIcon);
16 });
17
18 it("uses the workspace directory when the project name is blank", () => {
19 expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile");
20 });
21
22 it("uses Lucide icons for automatic project icons", () => {
23 expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({
24 kind: "lucide",
25 icon: "ai",
26 });
27 });
28
29 it("gives unknown names a stable generic icon", () => {
30 const icon = selectProjectIcon("mercury", "/workspace/mercury");
31
32 expect(icon.kind).toBe("lucide");
33 expect(PROJECT_ICON_NAMES).toContain(icon.icon);
34 expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon);
35 });
36});
apps/web/src/projectIconModel.ts deleted-192
......@@ -1,192 +0,0 @@
1export const PROJECT_ICON_NAMES = [
2 "ai",
3 "book",
4 "cloud",
5 "database",
6 "desktop",
7 "game",
8 "image",
9 "mobile",
10 "music",
11 "package",
12 "security",
13 "server",
14 "shopping",
15 "terminal",
16 "test",
17 "video",
18 "web",
19 "code",
20 "braces",
21 "circuit",
22 "folder-code",
23 "layers",
24] as const;
25
26export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number];
27
28export type ProjectIconSelection =
29 | { readonly kind: "lucide"; readonly icon: ProjectIconName }
30 | { readonly kind: "emoji"; readonly icon: ProjectIconName; readonly emoji: string };
31
32interface ProjectIconClass {
33 readonly icon: ProjectIconName;
34 readonly terms: ReadonlyArray<string>;
35}
36
37const PROJECT_ICON_MODEL: ReadonlyArray<ProjectIconClass> = [
38 { icon: "ai", terms: ["ai", "agent", "bot", "gpt", "llm", "ml", "model", "neural"] },
39 {
40 icon: "mobile",
41 terms: ["android", "expo", "ios", "mobile", "native", "reactnative", "swift"],
42 },
43 {
44 icon: "desktop",
45 terms: ["desktop", "electron", "linux", "mac", "macos", "tauri", "windows"],
46 },
47 {
48 icon: "book",
49 terms: ["book", "docs", "documentation", "guide", "handbook", "manual", "wiki"],
50 },
51 {
52 icon: "security",
53 terms: ["auth", "identity", "oauth", "security", "sso", "vault"],
54 },
55 {
56 icon: "database",
57 terms: [
58 "analytics",
59 "data",
60 "database",
61 "db",
62 "mongo",
63 "mysql",
64 "postgres",
65 "redis",
66 "sql",
67 "storage",
68 ],
69 },
70 {
71 icon: "cloud",
72 terms: [
73 "aws",
74 "azure",
75 "cloud",
76 "deploy",
77 "devops",
78 "docker",
79 "gcp",
80 "infra",
81 "kubernetes",
82 "terraform",
83 ],
84 },
85 {
86 icon: "server",
87 terms: ["api", "backend", "gateway", "server", "service", "worker"],
88 },
89 {
90 icon: "terminal",
91 terms: ["automation", "bash", "cli", "command", "script", "shell", "terminal"],
92 },
93 {
94 icon: "package",
95 terms: ["component", "kit", "lib", "library", "package", "plugin", "sdk", "toolkit"],
96 },
97 {
98 icon: "test",
99 terms: ["benchmark", "e2e", "fixture", "spec", "test", "testing"],
100 },
101 {
102 icon: "shopping",
103 terms: ["cart", "commerce", "market", "shop", "store"],
104 },
105 { icon: "game", terms: ["game", "gaming", "play"] },
106 { icon: "music", terms: ["audio", "music", "podcast", "radio", "sound"] },
107 { icon: "video", terms: ["film", "movie", "stream", "video"] },
108 { icon: "image", terms: ["camera", "gallery", "image", "photo", "picture"] },
109 {
110 icon: "web",
111 terms: [
112 "browser",
113 "frontend",
114 "nextjs",
115 "react",
116 "site",
117 "svelte",
118 "ui",
119 "vue",
120 "web",
121 "website",
122 ],
123 },
124];
125
126const GENERIC_PROJECT_ICONS: ReadonlyArray<ProjectIconName> = [
127 "code",
128 "braces",
129 "circuit",
130 "folder-code",
131 "layers",
132];
133
134const projectIconCache = new Map<string, ProjectIconSelection>();
135
136function projectNameTokens(value: string): ReadonlyArray<string> {
137 return value
138 .replace(/([a-z\d])([A-Z])/g, "$1 $2")
139 .toLowerCase()
140 .split(/[^a-z\d]+/)
141 .filter(Boolean);
142}
143
144function termScore(token: string, term: string): number {
145 if (token === term) return 3;
146 if (term.length >= 4 && (token.startsWith(term) || token.endsWith(term))) return 1;
147 return 0;
148}
149
150function stableIndex(value: string, length: number): number {
151 let hash = 2_166_136_261;
152 for (let index = 0; index < value.length; index += 1) {
153 hash ^= value.charCodeAt(index);
154 hash = Math.imul(hash, 16_777_619);
155 }
156 return (hash >>> 0) % length;
157}
158
159export function selectProjectIcon(
160 projectName: string,
161 workspaceRoot: string,
162): ProjectIconSelection {
163 const name = projectName.trim() || workspaceRoot.split(/[\\/]+/).findLast(Boolean) || "project";
164 const cacheKey = name.toLowerCase();
165 const cachedIcon = projectIconCache.get(cacheKey);
166 if (cachedIcon) return cachedIcon;
167
168 const tokens = projectNameTokens(name);
169 let bestIcon: ProjectIconName | null = null;
170 let bestScore = 0;
171
172 for (const projectClass of PROJECT_ICON_MODEL) {
173 let score = 0;
174 for (const token of tokens) {
175 let tokenScore = 0;
176 for (const term of projectClass.terms) {
177 tokenScore = Math.max(tokenScore, termScore(token, term));
178 }
179 score += tokenScore;
180 }
181 if (score > bestScore) {
182 bestIcon = projectClass.icon;
183 bestScore = score;
184 }
185 }
186
187 const iconName =
188 bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!;
189 const icon: ProjectIconSelection = { kind: "lucide", icon: iconName };
190 projectIconCache.set(cacheKey, icon);
191 return icon;
192}
apps/web/src/projectIdentity.test.ts created+34
......@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vite-plus/test";
2import { deriveProjectIdentity } from "./projectIdentity";
3
4describe("deriveProjectIdentity", () => {
5 it.each([
6 ["Nebula", "NA"],
7 ["Silver Orchard", "SO"],
8 ["Quiet Lantern Workshop", "QW"],
9 ["m7forge", "M7"],
10 ["M7 Forge", "M7"],
11 ["X", "XX"],
12 ["---", "PR"],
13 ])("derives %s as %s", (projectName, expected) => {
14 expect(deriveProjectIdentity(projectName).monogram).toBe(expected);
15 });
16
17 it("keeps the palette stable across case and surrounding whitespace", () => {
18 const canonical = deriveProjectIdentity("Nebula");
19 const equivalent = deriveProjectIdentity(" NEBULA ");
20
21 expect(equivalent.background).toBe(canonical.background);
22 expect(equivalent.highlight).toBe(canonical.highlight);
23 });
24
25 it("generates different hues for different project names", () => {
26 const colors = new Set(
27 ["Nebula", "M7 Forge", "Silver Orchard", "Blue Harbor", "Copper Finch", "Juniper Vale"].map(
28 (projectName) => deriveProjectIdentity(projectName).background,
29 ),
30 );
31
32 expect(colors.size).toBeGreaterThan(1);
33 });
34});
apps/web/src/projectIdentity.ts created+43
......@@ -0,0 +1,43 @@
1/** Visual identity tokens for a generated project badge. */
2export interface ProjectIdentity {
3 readonly monogram: string;
4 readonly background: string;
5 readonly highlight: string;
6}
7
8function normalizeProjectName(projectName: string): string {
9 return projectName.normalize("NFKC").trim();
10}
11
12function projectMonogram(projectName: string): string {
13 const words = normalizeProjectName(projectName).match(/[\p{L}\p{N}]+/gu) ?? [];
14 const firstWord = words[0];
15 if (!firstWord) return "PR";
16
17 const glyphs = Array.from(firstWord);
18 const first = glyphs[0] ?? "P";
19 const second =
20 glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ??
21 (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ??
22 first;
23 return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");
24}
25
26function projectHue(projectName: string): number {
27 const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project";
28 let hue = 0;
29 for (const glyph of seed) {
30 hue = (hue * 31 + (glyph.codePointAt(0) ?? 0)) % 360;
31 }
32 return hue;
33}
34
35/** Derives the stable monogram and generated colors used when a project has no icon. */
36export function deriveProjectIdentity(projectName: string): ProjectIdentity {
37 const hue = projectHue(projectName);
38 return {
39 monogram: projectMonogram(projectName),
40 background: `hsl(${hue} 48% 36%)`,
41 highlight: `hsl(${(hue + 24) % 360} 58% 48%)`,
42 };
43}
docs/user/project-settings.md+4
......@@ -47,6 +47,10 @@ Select the project and open Project to choose an icon, emoji, or image. The choi
4747every checkout in the project group and appears on connected clients. Choose **Automatic** to let
4848T3 Code detect an icon again.
4949
50When no image is found, web and desktop show a two-character monogram with colors
51derived from the saved project name. For example, `Nebula` becomes `NA`,
52`Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`.
53
5054## Keep the default branch current
5155
5256In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date