diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index 21c95142929f..90f7470a8c5c 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -4353,6 +4353,45 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
}),
);
+ it.effect("persists and clears a project monogram", () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngineService;
+ const sql = yield* SqlClient.SqlClient;
+ const projectId = ProjectId.make("project-monogram");
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make("cmd-monogram-create"),
+ projectId,
+ title: "Monogram",
+ workspaceRoot: "/tmp/project-monogram",
+ defaultModelSelection: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ });
+ yield* engine.dispatch({
+ type: "project.meta.update",
+ commandId: CommandId.make("cmd-monogram-save"),
+ projectId,
+ projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" },
+ });
+ const saved = yield* sql<{
+ readonly icon: string | null;
+ }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`;
+ assert.deepEqual(saved, [
+ { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' },
+ ]);
+ yield* engine.dispatch({
+ type: "project.meta.update",
+ commandId: CommandId.make("cmd-monogram-clear"),
+ projectId,
+ projectIcon: null,
+ });
+ const cleared = yield* sql<{
+ readonly icon: string | null;
+ }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`;
+ assert.deepEqual(cleared, [{ icon: null }]);
+ }),
+ );
+
it.effect("re-creating a deleted thread id starts from an empty projection", () =>
Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx
index 17006889fb9c..edf9f23e23a7 100644
--- a/apps/web/src/components/ProjectFavicon.tsx
+++ b/apps/web/src/components/ProjectFavicon.tsx
@@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react";
import { projectFaviconUrlAtom } from "../state/assets";
import { deriveProjectIdentity } from "../projectIdentity";
import { projectIconColorClassName } from "../projectIconColors";
+import { ProjectMonogram } from "./ProjectMonogram";
import { cn } from "~/lib/utils";
const DynamicIcon = lazy(() =>
@@ -42,6 +43,15 @@ export function ProjectFavicon(input: {
faviconPath: project.faviconPath,
}),
);
+ if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) {
+ return (
+
+ );
+ }
if (project.projectIcon?.kind === "emoji") {
return (
0) {
const identity = deriveProjectIdentity(projectName);
- // Wrapped like the emoji and Lucide branches so the monogram sits where an
- //
favicon would. Menu items, buttons and the like pull every bare svg
- // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this
- // tile has no such padding.
return (
-
-
-
+
);
}
diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx
new file mode 100644
index 000000000000..04a44e166ea2
--- /dev/null
+++ b/apps/web/src/components/ProjectMonogram.tsx
@@ -0,0 +1,52 @@
+import type { ProjectIconColor } from "@t3tools/contracts";
+import { projectIconColorClassName } from "../projectIconColors";
+import { cn } from "~/lib/utils";
+
+const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
+
+export function ProjectMonogram({
+ text,
+ color,
+ className,
+}: {
+ readonly text: string;
+ readonly color: ProjectIconColor;
+ readonly className?: string | undefined;
+}) {
+ // Wrapped like the emoji and Lucide branches so the monogram sits where an
+ //
favicon would. Menu items, buttons and the like pull every bare svg
+ // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this
+ // tile has no such padding.
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx
index 2280395ecf40..e1c08dfbabb4 100644
--- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx
+++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx
@@ -41,7 +41,13 @@ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";
describe("ProjectIconPickerDialog", () => {
it("shows icons first and selects them for an automatic project", () => {
const markup = renderToStaticMarkup(
- {}} onSelect={() => {}} />,
+ {}}
+ onSelect={() => {}}
+ />,
);
expect(markup).toContain('data-current="lucide"');
diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx
index 7fce7a4fbb5b..8bcdcc617ef7 100644
--- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx
+++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx
@@ -1,4 +1,11 @@
-import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { deriveProjectIdentity } from "../../projectIdentity";
+import { ProjectMonogram } from "../ProjectMonogram";
+import {
+ ProjectMonogramText,
+ type ProjectIconColor,
+ type ProjectIconOverride,
+} from "@t3tools/contracts";
import { DynamicIcon, type IconName } from "lucide-react/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import {
@@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area";
import { Toggle, ToggleGroup } from "../ui/toggle-group";
const DEFAULT_ICON: IconName = "folder-code";
-const DEFAULT_COLOR: ProjectIconColor = "blue";
+const isMonogramText = Schema.is(ProjectMonogramText);
function iconLabel(name: string): string {
return name
@@ -35,23 +42,29 @@ function iconLabel(name: string): string {
export function ProjectIconPickerDialog({
current,
+ projectName,
open,
onOpenChange,
onSelect,
}: {
readonly current: ProjectIconOverride | null;
+ readonly projectName: string;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
readonly onSelect: (icon: ProjectIconOverride) => void;
}) {
- const [mode, setMode] = useState<"lucide" | "emoji">(
- current?.kind === "emoji" ? "emoji" : "lucide",
+ const automatic = deriveProjectIdentity(projectName);
+ const [mode, setMode] = useState(
+ current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
);
const [iconName, setIconName] = useState(
current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
);
const [color, setColor] = useState(
- current?.kind === "lucide" ? current.color : DEFAULT_COLOR,
+ current && current.kind !== "emoji" ? current.color : automatic.color,
+ );
+ const [letters, setLetters] = useState(
+ current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,
);
const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");
const [query, setQuery] = useState("");
@@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({
useEffect(() => {
if (open && !previousOpenRef.current) {
- setMode(current?.kind === "emoji" ? "emoji" : "lucide");
+ setMode(
+ current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
+ );
setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
- setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR);
+ setColor(current && current.kind !== "emoji" ? current.color : automatic.color);
+ setLetters(
+ current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,
+ );
setEmoji(current?.kind === "emoji" ? current.emoji : "💻");
setQuery("");
setCustomEmoji("");
}
previousOpenRef.current = open;
- }, [current, open]);
+ }, [current, open, automatic.color, automatic.monogram]);
const icons = useMemo(() => filterProjectIconNames(query), [query]);
const selectedColorClassName = projectIconColorClassName(color);
+ const monogram = letters.normalize("NFKC").trim().toUpperCase();
+ const validMonogram = isMonogramText(monogram);
const save = () => {
+ if (mode === "monogram" && !validMonogram) return;
onSelect(
- mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji },
+ mode === "monogram"
+ ? { kind: "lucide", name: DEFAULT_ICON, monogram, color }
+ : mode === "lucide"
+ ? { kind: "lucide", name: iconName, color }
+ : { kind: "emoji", emoji },
);
onOpenChange(false);
};
@@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({
Choose project icon
- Pick any Lucide icon and color, or use an emoji.
+ Choose an icon, emoji, or monogram.
{
const value = next[0];
- if (value === "lucide" || value === "emoji") setMode(value);
+ if (value === "lucide" || value === "emoji" || value === "monogram") setMode(value);
}}
>
Icons
Emoji
+ Monogram
+ {mode !== "emoji" ? (
+
+
Color
+
+ {PROJECT_ICON_COLORS.map((option) => (
+
+ ))}
+
+
+ ) : null}
+
{mode === "lucide" ? (
<>
-
-
Color
-
- {PROJECT_ICON_COLORS.map((option) => (
-
- ))}
-
-
No icons found.
) : null}
>
+ ) : mode === "monogram" ? (
+
+
+
+
+
setLetters(event.currentTarget.value)}
+ aria-describedby="project-monogram-hint"
+ aria-invalid={!validMonogram}
+ autoComplete="off"
+ />
+
+ One or two letters or numbers.
+
+
+
) : (
<>
@@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({
-
+
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
index 1a0f31d77836..3e4cfd22a937 100644
--- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
@@ -435,7 +435,7 @@ function ProjectDetail({
title="Project icon"
description={
projectIcon?.kind === "lucide"
- ? `${projectIcon.name} · ${projectIcon.color}`
+ ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}`
: projectIcon?.kind === "emoji"
? projectIcon.emoji
: (faviconPath ?? "Automatic")
@@ -530,6 +530,7 @@ function ProjectDetail({
void setProjectIcon({ faviconPath: null, projectIcon: icon })}
diff --git a/apps/web/src/projectIdentity.test.ts b/apps/web/src/projectIdentity.test.ts
index 942e76fc91b8..250fce32810a 100644
--- a/apps/web/src/projectIdentity.test.ts
+++ b/apps/web/src/projectIdentity.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vite-plus/test";
+import { PROJECT_ICON_COLORS } from "./projectIconColors";
import { deriveProjectIdentity } from "./projectIdentity";
describe("deriveProjectIdentity", () => {
@@ -18,14 +19,20 @@ describe("deriveProjectIdentity", () => {
const canonical = deriveProjectIdentity("Nebula");
const equivalent = deriveProjectIdentity(" NEBULA ");
- expect(equivalent.background).toBe(canonical.background);
- expect(equivalent.highlight).toBe(canonical.highlight);
+ expect(equivalent.color).toBe(canonical.color);
+ });
+
+ it("uses only colors available in the icon picker", () => {
+ const palette = PROJECT_ICON_COLORS.map(({ value }) => value);
+ for (const name of ["Jobs", "Scripts and Extractors", "T3", "文書", "", "---"]) {
+ expect(palette).toContain(deriveProjectIdentity(name).color);
+ }
});
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,
+ (projectName) => deriveProjectIdentity(projectName).color,
),
);
diff --git a/apps/web/src/projectIdentity.ts b/apps/web/src/projectIdentity.ts
index 661a07553a65..840856aa403d 100644
--- a/apps/web/src/projectIdentity.ts
+++ b/apps/web/src/projectIdentity.ts
@@ -1,8 +1,10 @@
+import { PROJECT_ICON_COLORS } from "./projectIconColors";
+import type { ProjectIconColor } from "@t3tools/contracts";
+
/** Visual identity tokens for a generated project badge. */
export interface ProjectIdentity {
readonly monogram: string;
- readonly background: string;
- readonly highlight: string;
+ readonly color: ProjectIconColor;
}
function normalizeProjectName(projectName: string): string {
@@ -23,21 +25,19 @@ function projectMonogram(projectName: string): string {
return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");
}
-function projectHue(projectName: string): number {
+function projectColor(projectName: string): ProjectIconColor {
const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project";
- let hue = 0;
+ let index = 0;
for (const glyph of seed) {
- hue = (hue * 31 + (glyph.codePointAt(0) ?? 0)) % 360;
+ index = (index * 31 + (glyph.codePointAt(0) ?? 0)) % PROJECT_ICON_COLORS.length;
}
- return hue;
+ return PROJECT_ICON_COLORS[index]?.value ?? "blue";
}
/** 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%)`,
+ color: projectColor(projectName),
};
}
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index 2985c6c011c9..372e92b9a10a 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -43,12 +43,14 @@ Browser access changes apply when an agent session next starts.
## Project icons
-Select the project and open Project to choose an icon, emoji, or image. The choice applies to
+Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to
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`,
+Choose **Monogram** in the icon picker to set one or two letters or numbers and a color.
+
+When no image is found, web and desktop show a two-character monogram with a color
+from the icon palette, 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
diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts
index b8c8358813d4..5228e296d68f 100644
--- a/packages/contracts/src/orchestration.test.ts
+++ b/packages/contracts/src/orchestration.test.ts
@@ -17,6 +17,8 @@ import {
OrchestrationGetTurnDiffInput,
OrchestrationLatestTurn,
ProjectCreatedPayload,
+ OrchestrationProjectShell,
+ ProjectIconColor,
ProjectMetaUpdatedPayload,
OrchestrationProposedPlan,
OrchestrationSession,
@@ -40,6 +42,18 @@ import { ProviderInstanceId } from "./providerInstance.ts";
const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);
const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
+// The icon shape understood by clients released before monograms.
+const legacyProjectIcon = Schema.Union([
+ Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: ProjectIconColor }),
+ Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
+]);
+const decodeLegacyProjectShell = Schema.decodeUnknownEffect(
+ Schema.Struct({
+ ...OrchestrationProjectShell.fields,
+ projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)),
+ }),
+);
+const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);
const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
@@ -1494,6 +1508,64 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
}),
);
+it.effect("older clients decode monogram projects as their fallback icon", () =>
+ Effect.gen(function* () {
+ const encoded = yield* encodeProjectShell({
+ id: ProjectId.make("project-monogram"),
+ title: "Monogram",
+ workspaceRoot: "/tmp/monogram",
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" },
+ });
+ const decoded = yield* decodeLegacyProjectShell(encoded);
+ assert.deepEqual(decoded.projectIcon, { kind: "lucide", name: "folder-code", color: "violet" });
+ }),
+);
+
+it.effect("project monograms validate text and palette colors", () =>
+ Effect.gen(function* () {
+ for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) {
+ const projectIcon = {
+ kind: "lucide",
+ name: "folder-code",
+ color: "violet",
+ monogram: text,
+ } as const;
+ const command = yield* decodeOrchestrationCommand({
+ type: "project.meta.update",
+ commandId: "cmd-monogram",
+ projectId: "project-1",
+ projectIcon,
+ });
+ assert.strictEqual(command.type, "project.meta.update");
+ if (command.type === "project.meta.update")
+ assert.deepEqual(command.projectIcon, projectIcon);
+ }
+ for (const projectIcon of [
+ { kind: "lucide", name: "folder-code", monogram: "", color: "blue" },
+ { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" },
+ { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" },
+ { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" },
+ { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" },
+ { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" },
+ { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" },
+ ]) {
+ const result = yield* Effect.exit(
+ decodeOrchestrationCommand({
+ type: "project.meta.update",
+ commandId: "cmd-monogram-invalid",
+ projectId: "project-1",
+ projectIcon,
+ }),
+ );
+ assert.strictEqual(result._tag, "Failure");
+ }
+ }),
+);
+
it.effect("rejects thread history imports without messages", () =>
Effect.gen(function* () {
const result = yield* Effect.exit(
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 1bb11ba0a673..6f2dbf733293 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -459,11 +459,20 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check(
const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
+const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
+export const ProjectMonogramText = TrimmedNonEmptyString.check(
+ Schema.isMaxLength(32),
+ Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
+ Schema.makeFilter((text) => Array.from(monogramSegmenter.segment(text)).length <= 2),
+);
+
export const ProjectIconOverride = Schema.Union([
Schema.Struct({
kind: Schema.Literal("lucide"),
name: ProjectLucideIconName,
color: ProjectIconColor,
+ // Older clients ignore this field and render the named Lucide icon instead.
+ monogram: Schema.optional(ProjectMonogramText),
}),
Schema.Struct({
kind: Schema.Literal("emoji"),