Skip to content

Commit 112a708

Browse files
ShpetimAmaria-rcks
andauthored
fix(web): use project monograms for automatic icon fallbacks (pingdotgg#11572)
Co-authored-by: maria-rcks <maria@kuuro.net>
1 parent f328db3 commit 112a708

7 files changed

Lines changed: 150 additions & 347 deletions

File tree

apps/web/src/components/ProjectFavicon.test.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 63 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,15 @@
1-
import type { ProjectIconColor } from "@t3tools/contracts";
21
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
32
import {
43
getProjectFaviconResourceKey,
54
isProjectFaviconFallbackUrl,
65
} from "@t3tools/shared/projectFavicon";
7-
import {
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";
6+
import { FolderCodeIcon } from "lucide-react";
317
import type { IconName } from "lucide-react/dynamic";
328
import type { ComponentType } from "react";
339
import { lazy, Suspense, useState } from "react";
3410
import { useAtomValue } from "@effect/atom-react";
3511
import { projectFaviconUrlAtom } from "../state/assets";
36-
import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
12+
import { deriveProjectIdentity } from "../projectIdentity";
3713
import { projectIconColorClassName } from "../projectIconColors";
3814
import { cn } from "~/lib/utils";
3915

@@ -45,56 +21,6 @@ function DynamicProjectIconFallback() {
4521
return <FolderCodeIcon className="size-full text-[inherit]" />;
4622
}
4723

48-
const 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-
73-
const 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-
10732
export 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

183102
function 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

212173
function 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

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)