| author | |
| committer | |
| log | 4e89d74436a167c01f25a6a5283638843398ea3a |
| tree | 056199c528a324b658245c789ba391392595f3bd |
| parent | 645d58547d282eb2aaf6c48e5907625fc112386a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 115 | testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; |
| 116 | 116 | }); |
| 117 | 117 | |
| 118 | it("shows a project-name emoji when no favicon exists", () => { | |
| 118 | it("shows a project-name icon when no favicon exists", () => { | |
| 119 | 119 | testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; |
| 120 | 120 | |
| 121 | 121 | const element = ProjectFavicon({ |
| 122 | 122 | environmentId: "environment-test" as EnvironmentId, |
| 123 | 123 | cwd: "/workspace/analytics-db", |
| 124 | 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 | }>; | |
| 126 | 130 | |
| 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 | }); |
| 129 | 135 | |
| 130 | it("chooses a deterministic semantic emoji", () => { | |
| 136 | it("chooses a deterministic semantic icon", () => { | |
| 131 | 137 | testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; |
| 132 | 138 | |
| 133 | 139 | const element = ProjectFavicon({ |
| 134 | 140 | environmentId: "environment-test" as EnvironmentId, |
| 135 | 141 | cwd: "/workspace/agent-runtime", |
| 136 | 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 | }>; | |
| 138 | 148 | |
| 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 | }); |
| 141 | 153 | |
| 142 | 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 | 39 | import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; |
| 40 | 40 | |
| 41 | 41 | describe("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 | 43 | const markup = renderToStaticMarkup( |
| 44 | 44 | <ProjectIconPickerDialog current={null} open onOpenChange={() => {}} onSelect={() => {}} />, |
| 45 | 45 | ); |
| 46 | 46 | |
| 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"'); | |
| 50 | 50 | }); |
| 51 | 51 | }); |
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+4-4| ... | ... | @@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({ |
| 45 | 45 | readonly onSelect: (icon: ProjectIconOverride) => void; |
| 46 | 46 | }) { |
| 47 | 47 | const [mode, setMode] = useState<"lucide" | "emoji">( |
| 48 | current?.kind === "lucide" ? "lucide" : "emoji", | |
| 48 | current?.kind === "emoji" ? "emoji" : "lucide", | |
| 49 | 49 | ); |
| 50 | 50 | const [iconName, setIconName] = useState<IconName>( |
| 51 | 51 | current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, |
| ... | ... | @@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({ |
| 60 | 60 | |
| 61 | 61 | useEffect(() => { |
| 62 | 62 | if (open && !previousOpenRef.current) { |
| 63 | setMode(current?.kind === "lucide" ? "lucide" : "emoji"); | |
| 63 | setMode(current?.kind === "emoji" ? "emoji" : "lucide"); | |
| 64 | 64 | setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); |
| 65 | 65 | setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); |
| 66 | 66 | setEmoji(current?.kind === "emoji" ? current.emoji : "๐ป"); |
| ... | ... | @@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({ |
| 84 | 84 | <DialogPopup className="w-full sm:w-[32rem]"> |
| 85 | 85 | <DialogHeader> |
| 86 | 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 | 88 | </DialogHeader> |
| 89 | 89 | <DialogPanel className="flex min-h-0 flex-col gap-4"> |
| 90 | 90 | <ToggleGroup |
| ... | ... | @@ -96,8 +96,8 @@ export function ProjectIconPickerDialog({ |
| 96 | 96 | if (value === "lucide" || value === "emoji") setMode(value); |
| 97 | 97 | }} |
| 98 | 98 | > |
| 99 | <Toggle value="emoji">Emoji</Toggle> | |
| 100 | 99 | <Toggle value="lucide">Icons</Toggle> |
| 100 | <Toggle value="emoji">Emoji</Toggle> | |
| 101 | 101 | </ToggleGroup> |
| 102 | 102 | |
| 103 | 103 | {mode === "lucide" ? ( |
apps/web/src/projectIconModel.test.ts+3-4| ... | ... | @@ -19,18 +19,17 @@ describe("selectProjectIcon", () => { |
| 19 | 19 | expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile"); |
| 20 | 20 | }); |
| 21 | 21 | |
| 22 | it("uses emoji for automatic project icons", () => { | |
| 22 | it("uses Lucide icons for automatic project icons", () => { | |
| 23 | 23 | expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({ |
| 24 | kind: "emoji", | |
| 24 | kind: "lucide", | |
| 25 | 25 | icon: "ai", |
| 26 | emoji: "๐ค", | |
| 27 | 26 | }); |
| 28 | 27 | }); |
| 29 | 28 | |
| 30 | 29 | it("gives unknown names a stable generic icon", () => { |
| 31 | 30 | const icon = selectProjectIcon("mercury", "/workspace/mercury"); |
| 32 | 31 | |
| 33 | expect(icon.kind).toBe("emoji"); | |
| 32 | expect(icon.kind).toBe("lucide"); | |
| 34 | 33 | expect(PROJECT_ICON_NAMES).toContain(icon.icon); |
| 35 | 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 | 131 | "layers", |
| 132 | 132 | ]; |
| 133 | 133 | |
| 134 | const 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 | ||
| 159 | 134 | const projectIconCache = new Map<string, ProjectIconSelection>(); |
| 160 | 135 | |
| 161 | 136 | function projectNameTokens(value: string): ReadonlyArray<string> { |
| ... | ... | @@ -211,11 +186,7 @@ export function selectProjectIcon( |
| 211 | 186 | |
| 212 | 187 | const iconName = |
| 213 | 188 | 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 }; | |
| 219 | 190 | projectIconCache.set(cacheKey, icon); |
| 220 | 191 | return icon; |
| 221 | 192 | } |
docs/user/project-settings.md+1-1| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon |
| 4 | 4 | paths, and icon links in project HTML files. If it does not find an image, it chooses a built-in |
| 5 | emoji from the project name. | |
| 5 | icon from the project name. | |
| 6 | 6 | |
| 7 | 7 | To choose a different icon or emoji: |
| 8 | 8 |