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