authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 13:53:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 13:53:16-04:00
log4e89d74436a167c01f25a6a5283638843398ea3a
tree056199c528a324b658245c789ba391392595f3bd
parent645d58547d282eb2aaf6c48e5907625fc112386a
signature Signed by PGP key B5690EEEBB952194

fix(web): make project icons the default (#9457)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

6 files changed, 31 insertions(+), 49 deletions(-)

apps/web/src/components/ProjectFavicon.test.tsx+18-6
......@@ -115,28 +115,40 @@ describe("ProjectFavicon", () => {
115115 testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg";
116116 });
117117
118 it("shows a project-name emoji when no favicon exists", () => {
118 it("shows a project-name icon when no favicon exists", () => {
119119 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
120120
121121 const element = ProjectFavicon({
122122 environmentId: "environment-test" as EnvironmentId,
123123 cwd: "/workspace/analytics-db",
124124 projectName: "analytics-db",
125 }) as ReactElement<{ readonly emoji?: string }>;
125 }) as ReactElement<{
126 readonly colorClassName?: string;
127 readonly emoji?: string;
128 readonly icon?: ComponentType<{ className?: string }>;
129 }>;
126130
127 expect(element.props.emoji).toBe("๐Ÿ—„๏ธ");
131 expect(element.props.icon).toBeDefined();
132 expect(element.props.emoji).toBeUndefined();
133 expect(element.props.colorClassName).toContain("text-cyan-600");
128134 });
129135
130 it("chooses a deterministic semantic emoji", () => {
136 it("chooses a deterministic semantic icon", () => {
131137 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
132138
133139 const element = ProjectFavicon({
134140 environmentId: "environment-test" as EnvironmentId,
135141 cwd: "/workspace/agent-runtime",
136142 projectName: "agent-runtime",
137 }) as ReactElement<{ readonly emoji?: string }>;
143 }) as ReactElement<{
144 readonly colorClassName?: string;
145 readonly emoji?: string;
146 readonly icon?: ComponentType<{ className?: string }>;
147 }>;
138148
139 expect(element.props.emoji).toBe("๐Ÿค–");
149 expect(element.props.icon).toBeDefined();
150 expect(element.props.emoji).toBeUndefined();
151 expect(element.props.colorClassName).toContain("text-violet-600");
140152 });
141153
142154 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {
apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx+4-4
......@@ -39,13 +39,13 @@ vi.mock("../ui/toggle-group", () => ({
3939import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";
4040
4141describe("ProjectIconPickerDialog", () => {
42 it("shows emoji first and selects it for an automatic project", () => {
42 it("shows icons first and selects them for an automatic project", () => {
4343 const markup = renderToStaticMarkup(
4444 <ProjectIconPickerDialog current={null} open onOpenChange={() => {}} onSelect={() => {}} />,
4545 );
4646
47 expect(markup).toContain('data-current="emoji"');
48 expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<"));
49 expect(markup).toContain("Or paste any emoji");
47 expect(markup).toContain('data-current="lucide"');
48 expect(markup.indexOf(">Icons<")).toBeLessThan(markup.indexOf(">Emoji<"));
49 expect(markup).toContain('aria-label="Icon color"');
5050 });
5151});
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+4-4
......@@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({
4545 readonly onSelect: (icon: ProjectIconOverride) => void;
4646}) {
4747 const [mode, setMode] = useState<"lucide" | "emoji">(
48 current?.kind === "lucide" ? "lucide" : "emoji",
48 current?.kind === "emoji" ? "emoji" : "lucide",
4949 );
5050 const [iconName, setIconName] = useState<IconName>(
5151 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
......@@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({
6060
6161 useEffect(() => {
6262 if (open && !previousOpenRef.current) {
63 setMode(current?.kind === "lucide" ? "lucide" : "emoji");
63 setMode(current?.kind === "emoji" ? "emoji" : "lucide");
6464 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
6565 setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR);
6666 setEmoji(current?.kind === "emoji" ? current.emoji : "๐Ÿ’ป");
......@@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({
8484 <DialogPopup className="w-full sm:w-[32rem]">
8585 <DialogHeader>
8686 <DialogTitle>Choose project icon</DialogTitle>
87 <DialogDescription>Pick an emoji, or choose any Lucide icon and color.</DialogDescription>
87 <DialogDescription>Pick any Lucide icon and color, or use an emoji.</DialogDescription>
8888 </DialogHeader>
8989 <DialogPanel className="flex min-h-0 flex-col gap-4">
9090 <ToggleGroup
......@@ -96,8 +96,8 @@ export function ProjectIconPickerDialog({
9696 if (value === "lucide" || value === "emoji") setMode(value);
9797 }}
9898 >
99 <Toggle value="emoji">Emoji</Toggle>
10099 <Toggle value="lucide">Icons</Toggle>
100 <Toggle value="emoji">Emoji</Toggle>
101101 </ToggleGroup>
102102
103103 {mode === "lucide" ? (
apps/web/src/projectIconModel.test.ts+3-4
......@@ -19,18 +19,17 @@ describe("selectProjectIcon", () => {
1919 expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile");
2020 });
2121
22 it("uses emoji for automatic project icons", () => {
22 it("uses Lucide icons for automatic project icons", () => {
2323 expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({
24 kind: "emoji",
24 kind: "lucide",
2525 icon: "ai",
26 emoji: "๐Ÿค–",
2726 });
2827 });
2928
3029 it("gives unknown names a stable generic icon", () => {
3130 const icon = selectProjectIcon("mercury", "/workspace/mercury");
3231
33 expect(icon.kind).toBe("emoji");
32 expect(icon.kind).toBe("lucide");
3433 expect(PROJECT_ICON_NAMES).toContain(icon.icon);
3534 expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon);
3635 });
apps/web/src/projectIconModel.ts+1-30
......@@ -131,31 +131,6 @@ const GENERIC_PROJECT_ICONS: ReadonlyArray<ProjectIconName> = [
131131 "layers",
132132];
133133
134const PROJECT_ICON_EMOJIS: Record<ProjectIconName, string> = {
135 ai: "๐Ÿค–",
136 book: "๐Ÿ“š",
137 braces: "๐Ÿงฉ",
138 circuit: "โšก",
139 cloud: "โ˜๏ธ",
140 code: "๐Ÿ’ป",
141 database: "๐Ÿ—„๏ธ",
142 desktop: "๐Ÿ–ฅ๏ธ",
143 "folder-code": "๐Ÿ› ๏ธ",
144 game: "๐ŸŽฎ",
145 image: "๐Ÿ–ผ๏ธ",
146 layers: "โœจ",
147 mobile: "๐Ÿ“ฑ",
148 music: "๐ŸŽต",
149 package: "๐Ÿ“ฆ",
150 security: "๐Ÿ”’",
151 server: "โš™๏ธ",
152 shopping: "๐Ÿ›๏ธ",
153 terminal: "โŒจ๏ธ",
154 test: "๐Ÿงช",
155 video: "๐ŸŽฌ",
156 web: "๐ŸŒ",
157};
158
159134const projectIconCache = new Map<string, ProjectIconSelection>();
160135
161136function projectNameTokens(value: string): ReadonlyArray<string> {
......@@ -211,11 +186,7 @@ export function selectProjectIcon(
211186
212187 const iconName =
213188 bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!;
214 const icon: ProjectIconSelection = {
215 kind: "emoji",
216 icon: iconName,
217 emoji: PROJECT_ICON_EMOJIS[iconName],
218 };
189 const icon: ProjectIconSelection = { kind: "lucide", icon: iconName };
219190 projectIconCache.set(cacheKey, icon);
220191 return icon;
221192}
docs/user/project-settings.md+1-1
......@@ -2,7 +2,7 @@
22
33T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon
44paths, and icon links in project HTML files. If it does not find an image, it chooses a built-in
5emoji from the project name.
5icon from the project name.
66
77To choose a different icon or emoji:
88