diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 854301bee7bc..b60cf3ae62c4 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -122,36 +122,28 @@ describe("ProjectFavicon", () => { testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; }); - it("shows a project-name icon when no favicon exists", () => { + it("shows the project monogram when no favicon exists", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), }) as ReactElement<{ - readonly colorClassName?: string; - readonly emoji?: string; - readonly icon?: ComponentType<{ className?: string }>; + readonly projectName?: string; }>; - expect(element.props.icon).toBeDefined(); - expect(element.props.emoji).toBeUndefined(); - expect(element.props.colorClassName).toContain("text-cyan-600"); + expect(element.props.projectName).toBe("analytics-db"); }); - it("chooses a deterministic semantic icon", () => { + it("uses the same monogram fallback for every project category", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), }) as ReactElement<{ - readonly colorClassName?: string; - readonly emoji?: string; - readonly icon?: ComponentType<{ className?: string }>; + readonly projectName?: string; }>; - expect(element.props.icon).toBeDefined(); - expect(element.props.emoji).toBeUndefined(); - expect(element.props.colorClassName).toContain("text-violet-600"); + expect(element.props.projectName).toBe("agent-runtime"); }); it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 15597e786c31..c545fb188880 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,39 +1,15 @@ -import type { ProjectIconColor } from "@t3tools/contracts"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { - BotIcon, - BookOpenIcon, - BracesIcon, - CircuitBoardIcon, - CloudCogIcon, - Code2Icon, - DatabaseIcon, - FlaskConicalIcon, - FolderCodeIcon, - Gamepad2Icon, - Globe2Icon, - ImageIcon, - Layers3Icon, - MonitorIcon, - MusicIcon, - PackageIcon, - ServerIcon, - ShieldCheckIcon, - ShoppingBagIcon, - SmartphoneIcon, - TerminalIcon, - VideoIcon, -} from "lucide-react"; +import { FolderCodeIcon } from "lucide-react"; import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; -import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; +import { deriveProjectIdentity } from "../projectIdentity"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; @@ -45,56 +21,6 @@ function DynamicProjectIconFallback() { return ; } -const PROJECT_ICONS: Record> = { - ai: BotIcon, - book: BookOpenIcon, - braces: BracesIcon, - circuit: CircuitBoardIcon, - cloud: CloudCogIcon, - code: Code2Icon, - database: DatabaseIcon, - desktop: MonitorIcon, - "folder-code": FolderCodeIcon, - game: Gamepad2Icon, - image: ImageIcon, - layers: Layers3Icon, - mobile: SmartphoneIcon, - music: MusicIcon, - package: PackageIcon, - security: ShieldCheckIcon, - server: ServerIcon, - shopping: ShoppingBagIcon, - terminal: TerminalIcon, - test: FlaskConicalIcon, - video: VideoIcon, - web: Globe2Icon, -}; - -const PROJECT_ICON_COLOR_BY_NAME: Record = { - ai: "violet", - book: "amber", - braces: "purple", - circuit: "teal", - cloud: "sky", - code: "blue", - database: "cyan", - desktop: "indigo", - "folder-code": "orange", - game: "emerald", - image: "pink", - layers: "fuchsia", - mobile: "lime", - music: "fuchsia", - package: "orange", - security: "teal", - server: "blue", - shopping: "rose", - terminal: "green", - test: "yellow", - video: "red", - web: "sky", -}; - // The slice of a project that decides its icon. Every surface must pass the // project record itself (or a snapshot spread from it) so the saved title, favicon // and icon override always travel together. Passing a display label as the title @@ -103,7 +29,6 @@ export type ProjectFaviconProject = Pick< EnvironmentProject, "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon" >; - export function ProjectFavicon(input: { project: ProjectFaviconProject; className?: string | undefined; @@ -118,7 +43,13 @@ export function ProjectFavicon(input: { }), ); if (project.projectIcon?.kind === "emoji") { - return ; + return ( + + ); } if (project.projectIcon?.kind === "lucide") { const colorClassName = projectIconColorClassName(project.projectIcon.color); @@ -139,25 +70,14 @@ export function ProjectFavicon(input: { ); } - const automaticIconName = input.fallbackIcon - ? null - : selectProjectIcon(project.title, project.workspaceRoot); - const FallbackIcon = - input.fallbackIcon ?? - (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined); - const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined; - const fallbackColorClassName = - automaticIconName?.kind === "lucide" - ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon]) - : undefined; + const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; if (!src || isProjectFaviconFallbackUrl(src)) { return ( ); } @@ -174,23 +94,65 @@ export function ProjectFavicon(input: { src={src} className={input.className} fallbackIcon={FallbackIcon} - fallbackEmoji={fallbackEmoji} - fallbackColorClassName={fallbackColorClassName} + fallbackProjectName={project.title} /> ); } function ProjectFaviconFallback({ className, - colorClassName, icon: Icon, emoji, + projectName, }: { readonly className?: string | undefined; - readonly colorClassName?: string | undefined; - readonly icon?: ComponentType<{ className?: string }> | undefined; + readonly icon: ComponentType<{ className?: string }>; readonly emoji?: string | undefined; + readonly projectName?: string | undefined; }) { + if (projectName && projectName.trim().length > 0) { + const identity = deriveProjectIdentity(projectName); + return ( + + ); + } + if (emoji) { return ( ; + return ; } function ProjectFaviconImage({ src, className, fallbackIcon: FallbackIcon, - fallbackEmoji, - fallbackColorClassName, + fallbackProjectName, }: { readonly src: string; readonly className?: string | undefined; - readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; - readonly fallbackEmoji?: string | undefined; - readonly fallbackColorClassName?: string | undefined; + readonly fallbackIcon: ComponentType<{ className?: string }>; + readonly fallbackProjectName?: string | undefined; }) { const [displayedSrc, setDisplayedSrc] = useState(() => src.startsWith("data:image/") ? src : null, @@ -235,9 +194,8 @@ function ProjectFaviconImage({ {displayedSrc === null ? ( ) : null} {displayedSrc ? ( diff --git a/apps/web/src/projectIconModel.test.ts b/apps/web/src/projectIconModel.test.ts deleted file mode 100644 index 77e0ca2acb19..000000000000 --- a/apps/web/src/projectIconModel.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { PROJECT_ICON_NAMES, selectProjectIcon } from "./projectIconModel"; - -describe("selectProjectIcon", () => { - it.each([ - ["customer-api", "server"], - ["AnalyticsDatabase", "database"], - ["ios-client", "mobile"], - ["terraform-infra", "cloud"], - ["developer-docs", "book"], - ["shop-frontend", "shopping"], - ["agent-runtime", "ai"], - ["video-studio", "video"], - ] as const)("classifies %s as %s", (projectName, expectedIcon) => { - expect(selectProjectIcon(projectName, `/workspace/${projectName}`).icon).toBe(expectedIcon); - }); - - it("uses the workspace directory when the project name is blank", () => { - expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile"); - }); - - it("uses Lucide icons for automatic project icons", () => { - expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({ - kind: "lucide", - icon: "ai", - }); - }); - - it("gives unknown names a stable generic icon", () => { - const icon = selectProjectIcon("mercury", "/workspace/mercury"); - - expect(icon.kind).toBe("lucide"); - expect(PROJECT_ICON_NAMES).toContain(icon.icon); - expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon); - }); -}); diff --git a/apps/web/src/projectIconModel.ts b/apps/web/src/projectIconModel.ts deleted file mode 100644 index 8614ff2a41ff..000000000000 --- a/apps/web/src/projectIconModel.ts +++ /dev/null @@ -1,192 +0,0 @@ -export const PROJECT_ICON_NAMES = [ - "ai", - "book", - "cloud", - "database", - "desktop", - "game", - "image", - "mobile", - "music", - "package", - "security", - "server", - "shopping", - "terminal", - "test", - "video", - "web", - "code", - "braces", - "circuit", - "folder-code", - "layers", -] as const; - -export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number]; - -export type ProjectIconSelection = - | { readonly kind: "lucide"; readonly icon: ProjectIconName } - | { readonly kind: "emoji"; readonly icon: ProjectIconName; readonly emoji: string }; - -interface ProjectIconClass { - readonly icon: ProjectIconName; - readonly terms: ReadonlyArray; -} - -const PROJECT_ICON_MODEL: ReadonlyArray = [ - { icon: "ai", terms: ["ai", "agent", "bot", "gpt", "llm", "ml", "model", "neural"] }, - { - icon: "mobile", - terms: ["android", "expo", "ios", "mobile", "native", "reactnative", "swift"], - }, - { - icon: "desktop", - terms: ["desktop", "electron", "linux", "mac", "macos", "tauri", "windows"], - }, - { - icon: "book", - terms: ["book", "docs", "documentation", "guide", "handbook", "manual", "wiki"], - }, - { - icon: "security", - terms: ["auth", "identity", "oauth", "security", "sso", "vault"], - }, - { - icon: "database", - terms: [ - "analytics", - "data", - "database", - "db", - "mongo", - "mysql", - "postgres", - "redis", - "sql", - "storage", - ], - }, - { - icon: "cloud", - terms: [ - "aws", - "azure", - "cloud", - "deploy", - "devops", - "docker", - "gcp", - "infra", - "kubernetes", - "terraform", - ], - }, - { - icon: "server", - terms: ["api", "backend", "gateway", "server", "service", "worker"], - }, - { - icon: "terminal", - terms: ["automation", "bash", "cli", "command", "script", "shell", "terminal"], - }, - { - icon: "package", - terms: ["component", "kit", "lib", "library", "package", "plugin", "sdk", "toolkit"], - }, - { - icon: "test", - terms: ["benchmark", "e2e", "fixture", "spec", "test", "testing"], - }, - { - icon: "shopping", - terms: ["cart", "commerce", "market", "shop", "store"], - }, - { icon: "game", terms: ["game", "gaming", "play"] }, - { icon: "music", terms: ["audio", "music", "podcast", "radio", "sound"] }, - { icon: "video", terms: ["film", "movie", "stream", "video"] }, - { icon: "image", terms: ["camera", "gallery", "image", "photo", "picture"] }, - { - icon: "web", - terms: [ - "browser", - "frontend", - "nextjs", - "react", - "site", - "svelte", - "ui", - "vue", - "web", - "website", - ], - }, -]; - -const GENERIC_PROJECT_ICONS: ReadonlyArray = [ - "code", - "braces", - "circuit", - "folder-code", - "layers", -]; - -const projectIconCache = new Map(); - -function projectNameTokens(value: string): ReadonlyArray { - return value - .replace(/([a-z\d])([A-Z])/g, "$1 $2") - .toLowerCase() - .split(/[^a-z\d]+/) - .filter(Boolean); -} - -function termScore(token: string, term: string): number { - if (token === term) return 3; - if (term.length >= 4 && (token.startsWith(term) || token.endsWith(term))) return 1; - return 0; -} - -function stableIndex(value: string, length: number): number { - let hash = 2_166_136_261; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 16_777_619); - } - return (hash >>> 0) % length; -} - -export function selectProjectIcon( - projectName: string, - workspaceRoot: string, -): ProjectIconSelection { - const name = projectName.trim() || workspaceRoot.split(/[\\/]+/).findLast(Boolean) || "project"; - const cacheKey = name.toLowerCase(); - const cachedIcon = projectIconCache.get(cacheKey); - if (cachedIcon) return cachedIcon; - - const tokens = projectNameTokens(name); - let bestIcon: ProjectIconName | null = null; - let bestScore = 0; - - for (const projectClass of PROJECT_ICON_MODEL) { - let score = 0; - for (const token of tokens) { - let tokenScore = 0; - for (const term of projectClass.terms) { - tokenScore = Math.max(tokenScore, termScore(token, term)); - } - score += tokenScore; - } - if (score > bestScore) { - bestIcon = projectClass.icon; - bestScore = score; - } - } - - const iconName = - bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!; - const icon: ProjectIconSelection = { kind: "lucide", icon: iconName }; - projectIconCache.set(cacheKey, icon); - return icon; -} diff --git a/apps/web/src/projectIdentity.test.ts b/apps/web/src/projectIdentity.test.ts new file mode 100644 index 000000000000..942e76fc91b8 --- /dev/null +++ b/apps/web/src/projectIdentity.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; +import { deriveProjectIdentity } from "./projectIdentity"; + +describe("deriveProjectIdentity", () => { + it.each([ + ["Nebula", "NA"], + ["Silver Orchard", "SO"], + ["Quiet Lantern Workshop", "QW"], + ["m7forge", "M7"], + ["M7 Forge", "M7"], + ["X", "XX"], + ["---", "PR"], + ])("derives %s as %s", (projectName, expected) => { + expect(deriveProjectIdentity(projectName).monogram).toBe(expected); + }); + + it("keeps the palette stable across case and surrounding whitespace", () => { + const canonical = deriveProjectIdentity("Nebula"); + const equivalent = deriveProjectIdentity(" NEBULA "); + + expect(equivalent.background).toBe(canonical.background); + expect(equivalent.highlight).toBe(canonical.highlight); + }); + + it("generates different hues for different project names", () => { + const colors = new Set( + ["Nebula", "M7 Forge", "Silver Orchard", "Blue Harbor", "Copper Finch", "Juniper Vale"].map( + (projectName) => deriveProjectIdentity(projectName).background, + ), + ); + + expect(colors.size).toBeGreaterThan(1); + }); +}); diff --git a/apps/web/src/projectIdentity.ts b/apps/web/src/projectIdentity.ts new file mode 100644 index 000000000000..661a07553a65 --- /dev/null +++ b/apps/web/src/projectIdentity.ts @@ -0,0 +1,43 @@ +/** Visual identity tokens for a generated project badge. */ +export interface ProjectIdentity { + readonly monogram: string; + readonly background: string; + readonly highlight: string; +} + +function normalizeProjectName(projectName: string): string { + return projectName.normalize("NFKC").trim(); +} + +function projectMonogram(projectName: string): string { + const words = normalizeProjectName(projectName).match(/[\p{L}\p{N}]+/gu) ?? []; + const firstWord = words[0]; + if (!firstWord) return "PR"; + + const glyphs = Array.from(firstWord); + const first = glyphs[0] ?? "P"; + const second = + glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ?? + (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ?? + first; + return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); +} + +function projectHue(projectName: string): number { + const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project"; + let hue = 0; + for (const glyph of seed) { + hue = (hue * 31 + (glyph.codePointAt(0) ?? 0)) % 360; + } + return hue; +} + +/** Derives the stable monogram and generated colors used when a project has no icon. */ +export function deriveProjectIdentity(projectName: string): ProjectIdentity { + const hue = projectHue(projectName); + return { + monogram: projectMonogram(projectName), + background: `hsl(${hue} 48% 36%)`, + highlight: `hsl(${(hue + 24) % 360} 58% 48%)`, + }; +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index e4fae5a86c3b..2985c6c011c9 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -47,6 +47,10 @@ Select the project and open Project to choose an icon, emoji, or image. The choi every checkout in the project group and appears on connected clients. Choose **Automatic** to let T3 Code detect an icon again. +When no image is found, web and desktop show a two-character monogram with colors +derived from the saved project name. For example, `Nebula` becomes `NA`, +`Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`. + ## Keep the default branch current In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date