diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx
index 932fc6779f20..0659c68654f6 100644
--- a/apps/mobile/src/components/ProjectFavicon.tsx
+++ b/apps/mobile/src/components/ProjectFavicon.tsx
@@ -1,8 +1,9 @@
import { SymbolView } from "./AppSymbol";
+import { AppText } from "./AppText";
import { Image } from "expo-image";
import { useLayoutEffect, useMemo, useState } from "react";
import { View } from "react-native";
-import type { EnvironmentId } from "@t3tools/contracts";
+import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts";
import {
getProjectFaviconCacheKey,
getProjectFaviconResourceKey,
@@ -11,6 +12,12 @@ import {
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/unstable/reactivity";
import { projectFaviconUrlAtom } from "../state/assets";
+import {
+ countGlyphs,
+ projectIconColorClassNames,
+ resolveProjectIconGlyph,
+ type ProjectIconGlyph,
+} from "../lib/projectIcon";
import {
beginProjectFaviconRequest,
@@ -30,10 +37,12 @@ export function ProjectFavicon(props: {
readonly projectTitle: string;
readonly workspaceRoot?: string | null;
readonly faviconPath?: string | null;
+ readonly projectIcon?: ProjectIconOverride | null;
}) {
const size = props.size ?? 42;
+ const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle);
const faviconUrl = useAtomValue(
- props.workspaceRoot == null
+ props.workspaceRoot == null || glyph !== null
? EMPTY_FAVICON_URL
: projectFaviconUrlAtom({
environmentId: props.environmentId,
@@ -51,6 +60,10 @@ export function ProjectFavicon(props: {
: getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
: null;
+ if (glyph !== null) {
+ return ;
+ }
+
return (
+
+ {glyph.emoji}
+
+
+ );
+ }
+
+ const colors = projectIconColorClassNames(glyph.color);
+ return (
+
+
+ {glyph.text}
+
+
+ );
+}
+
function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
readonly faviconUrl: string | null;
diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
index ea150180393c..b786f9f4066c 100644
--- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
+++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
@@ -151,6 +151,7 @@ function ProjectGroupLabel(props: {
diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
index f74542076be2..27a9b3217c7e 100644
--- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
@@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {
+ it("returns null without an override so the favicon path is used", () => {
+ expect(resolveProjectIconGlyph(null, "nebula")).toBeNull();
+ expect(resolveProjectIconGlyph(undefined, "nebula")).toBeNull();
+ });
+
+ it("passes emoji and monogram overrides through", () => {
+ expect(resolveProjectIconGlyph({ kind: "emoji", emoji: "🍎" }, "silver-orchard")).toEqual({
+ kind: "emoji",
+ emoji: "🍎",
+ });
+ expect(
+ resolveProjectIconGlyph({ kind: "monogram", text: "M7", color: "orange" }, "m7-forge"),
+ ).toEqual({ kind: "monogram", text: "M7", color: "orange" });
+ });
+
+ it("turns a lucide override into a monogram that keeps the chosen color", () => {
+ expect(
+ resolveProjectIconGlyph({ kind: "lucide", name: "rocket", color: "violet" }, "nebula"),
+ ).toEqual({ kind: "monogram", text: "NA", color: "violet" });
+ });
+});
+
+describe("projectMonogram", () => {
+ it.each([
+ ["nebula", "NA"],
+ ["silver-orchard", "SO"],
+ ["m7-forge", "M7"],
+ ["t3code", "T3"],
+ [" ", "PR"],
+ ])("derives %s -> %s", (title, expected) => {
+ expect(projectMonogram(title)).toBe(expected);
+ });
+});
diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts
new file mode 100644
index 000000000000..4e3914235a84
--- /dev/null
+++ b/apps/mobile/src/lib/projectIcon.ts
@@ -0,0 +1,78 @@
+import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
+
+export type ProjectIconGlyph =
+ | { readonly kind: "emoji"; readonly emoji: string }
+ | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor };
+
+/** Code point count; Hermes has no Intl.Segmenter, and monogram text is at most two glyphs. */
+export function countGlyphs(text: string): number {
+ return Array.from(text).length;
+}
+
+/** Mirrors the automatic monogram web derives from a project name when it has no favicon. */
+export function projectMonogram(projectName: string): string {
+ const words =
+ projectName
+ .normalize("NFKC")
+ .trim()
+ .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("");
+}
+
+/**
+ * Picks what mobile draws for an assigned project icon. Mobile does not bundle
+ * the Lucide set, so a Lucide override keeps its color and falls back to the
+ * project's monogram instead of the folder glyph.
+ */
+export function resolveProjectIconGlyph(
+ projectIcon: ProjectIconOverride | null | undefined,
+ projectTitle: string,
+): ProjectIconGlyph | null {
+ switch (projectIcon?.kind) {
+ case "emoji":
+ return { kind: "emoji", emoji: projectIcon.emoji };
+ case "monogram":
+ return { kind: "monogram", text: projectIcon.text, color: projectIcon.color };
+ case "lucide":
+ return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color };
+ case undefined:
+ return null;
+ }
+}
+
+const PROJECT_ICON_COLOR_CLASSES: Record<
+ ProjectIconColor,
+ { readonly text: string; readonly background: string }
+> = {
+ gray: { text: "text-gray-500", background: "bg-gray-500/15" },
+ red: { text: "text-red-500", background: "bg-red-500/15" },
+ orange: { text: "text-orange-500", background: "bg-orange-500/15" },
+ amber: { text: "text-amber-500", background: "bg-amber-500/15" },
+ yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" },
+ lime: { text: "text-lime-500", background: "bg-lime-500/15" },
+ green: { text: "text-green-500", background: "bg-green-500/15" },
+ emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" },
+ teal: { text: "text-teal-500", background: "bg-teal-500/15" },
+ cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" },
+ sky: { text: "text-sky-500", background: "bg-sky-500/15" },
+ blue: { text: "text-blue-500", background: "bg-blue-500/15" },
+ indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" },
+ violet: { text: "text-violet-500", background: "bg-violet-500/15" },
+ purple: { text: "text-purple-500", background: "bg-purple-500/15" },
+ fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" },
+ pink: { text: "text-pink-500", background: "bg-pink-500/15" },
+ rose: { text: "text-rose-500", background: "bg-rose-500/15" },
+};
+
+export function projectIconColorClassNames(color: ProjectIconColor) {
+ return PROJECT_ICON_COLOR_CLASSES[color];
+}