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", () => {...@@ -115,28 +115,40 @@ describe("ProjectFavicon", () => {
115 testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg";115 testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg";
116 });116 });
117117
118 it("shows a project-name emoji when no favicon exists", () => {118 it("shows a project-name icon when no favicon exists", () => {
119 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;119 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
120120
121 const element = ProjectFavicon({121 const element = ProjectFavicon({
122 environmentId: "environment-test" as EnvironmentId,122 environmentId: "environment-test" as EnvironmentId,
123 cwd: "/workspace/analytics-db",123 cwd: "/workspace/analytics-db",
124 projectName: "analytics-db",124 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");
128 });134 });
129135
130 it("chooses a deterministic semantic emoji", () => {136 it("chooses a deterministic semantic icon", () => {
131 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;137 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
132138
133 const element = ProjectFavicon({139 const element = ProjectFavicon({
134 environmentId: "environment-test" as EnvironmentId,140 environmentId: "environment-test" as EnvironmentId,
135 cwd: "/workspace/agent-runtime",141 cwd: "/workspace/agent-runtime",
136 projectName: "agent-runtime",142 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");
140 });152 });
141153
142 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {154 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", () => ({...@@ -39,13 +39,13 @@ vi.mock("../ui/toggle-group", () => ({
39import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";39import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";
4040
41describe("ProjectIconPickerDialog", () => {41describe("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", () => {
43 const markup = renderToStaticMarkup(43 const markup = renderToStaticMarkup(
44 <ProjectIconPickerDialog current={null} open onOpenChange={() => {}} onSelect={() => {}} />,44 <ProjectIconPickerDialog current={null} open onOpenChange={() => {}} onSelect={() => {}} />,
45 );45 );
4646
47 expect(markup).toContain('data-current="emoji"');47 expect(markup).toContain('data-current="lucide"');
48 expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<"));48 expect(markup.indexOf(">Icons<")).toBeLessThan(markup.indexOf(">Emoji<"));
49 expect(markup).toContain("Or paste any emoji");49 expect(markup).toContain('aria-label="Icon color"');
50 });50 });
51});51});
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+4-4
...@@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({...@@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({
45 readonly onSelect: (icon: ProjectIconOverride) => void;45 readonly onSelect: (icon: ProjectIconOverride) => void;
46}) {46}) {
47 const [mode, setMode] = useState<"lucide" | "emoji">(47 const [mode, setMode] = useState<"lucide" | "emoji">(
48 current?.kind === "lucide" ? "lucide" : "emoji",48 current?.kind === "emoji" ? "emoji" : "lucide",
49 );49 );
50 const [iconName, setIconName] = useState<IconName>(50 const [iconName, setIconName] = useState<IconName>(
51 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,51 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
...@@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({...@@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({
6060
61 useEffect(() => {61 useEffect(() => {
62 if (open && !previousOpenRef.current) {62 if (open && !previousOpenRef.current) {
63 setMode(current?.kind === "lucide" ? "lucide" : "emoji");63 setMode(current?.kind === "emoji" ? "emoji" : "lucide");
64 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);64 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
65 setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR);65 setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR);
66 setEmoji(current?.kind === "emoji" ? current.emoji : "๐Ÿ’ป");66 setEmoji(current?.kind === "emoji" ? current.emoji : "๐Ÿ’ป");
...@@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({...@@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({
84 <DialogPopup className="w-full sm:w-[32rem]">84 <DialogPopup className="w-full sm:w-[32rem]">
85 <DialogHeader>85 <DialogHeader>
86 <DialogTitle>Choose project icon</DialogTitle>86 <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>
88 </DialogHeader>88 </DialogHeader>
89 <DialogPanel className="flex min-h-0 flex-col gap-4">89 <DialogPanel className="flex min-h-0 flex-col gap-4">
90 <ToggleGroup90 <ToggleGroup
...@@ -96,8 +96,8 @@ export function ProjectIconPickerDialog({...@@ -96,8 +96,8 @@ export function ProjectIconPickerDialog({
96 if (value === "lucide" || value === "emoji") setMode(value);96 if (value === "lucide" || value === "emoji") setMode(value);
97 }}97 }}
98 >98 >
99 <Toggle value="emoji">Emoji</Toggle>
100 <Toggle value="lucide">Icons</Toggle>99 <Toggle value="lucide">Icons</Toggle>
100 <Toggle value="emoji">Emoji</Toggle>
101 </ToggleGroup>101 </ToggleGroup>
102102
103 {mode === "lucide" ? (103 {mode === "lucide" ? (
apps/web/src/projectIconModel.test.ts+3-4
...@@ -19,18 +19,17 @@ describe("selectProjectIcon", () => {...@@ -19,18 +19,17 @@ describe("selectProjectIcon", () => {
19 expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile");19 expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile");
20 });20 });
2121
22 it("uses emoji for automatic project icons", () => {22 it("uses Lucide icons for automatic project icons", () => {
23 expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({23 expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({
24 kind: "emoji",24 kind: "lucide",
25 icon: "ai",25 icon: "ai",
26 emoji: "๐Ÿค–",
27 });26 });
28 });27 });
2928
30 it("gives unknown names a stable generic icon", () => {29 it("gives unknown names a stable generic icon", () => {
31 const icon = selectProjectIcon("mercury", "/workspace/mercury");30 const icon = selectProjectIcon("mercury", "/workspace/mercury");
3231
33 expect(icon.kind).toBe("emoji");32 expect(icon.kind).toBe("lucide");
34 expect(PROJECT_ICON_NAMES).toContain(icon.icon);33 expect(PROJECT_ICON_NAMES).toContain(icon.icon);
35 expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon);34 expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon);
36 });35 });
apps/web/src/projectIconModel.ts+1-30
...@@ -131,31 +131,6 @@ const GENERIC_PROJECT_ICONS: ReadonlyArray<ProjectIconName> = [...@@ -131,31 +131,6 @@ const GENERIC_PROJECT_ICONS: ReadonlyArray<ProjectIconName> = [
131 "layers",131 "layers",
132];132];
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
159const projectIconCache = new Map<string, ProjectIconSelection>();134const projectIconCache = new Map<string, ProjectIconSelection>();
160135
161function projectNameTokens(value: string): ReadonlyArray<string> {136function projectNameTokens(value: string): ReadonlyArray<string> {
...@@ -211,11 +186,7 @@ export function selectProjectIcon(...@@ -211,11 +186,7 @@ export function selectProjectIcon(
211186
212 const iconName =187 const iconName =
213 bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!;188 bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!;
214 const icon: ProjectIconSelection = {189 const icon: ProjectIconSelection = { kind: "lucide", icon: iconName };
215 kind: "emoji",
216 icon: iconName,
217 emoji: PROJECT_ICON_EMOJIS[iconName],
218 };
219 projectIconCache.set(cacheKey, icon);190 projectIconCache.set(cacheKey, icon);
220 return icon;191 return icon;
221}192}
docs/user/project-settings.md+1-1
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
22
3T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon3T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon
4paths, and icon links in project HTML files. If it does not find an image, it chooses a built-in4paths, 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
7To choose a different icon or emoji:7To choose a different icon or emoji:
88