authorgravatar for 130890337+eimexdev@users.noreply.github.comeimexdev <130890337+eimexdev@users.noreply.github.com> 2026-09-15 10:43:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 14:43:47-03:00
log438465d6b7dd2e50dd38862a0db1252d2d72ecfc
tree114ae703143c8fc6e0b5bd1366357a28e7427a96
parentd07ffbe351a10e1001f6d8eafa5b7533b8bd7e23
signature Signed by PGP key B5690EEEBB952194

feat: add customizable soft-tint project monograms (#11845)


11 files changed, 303 insertions(+), 91 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+39
...@@ -4353,6 +4353,45 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -4353,6 +4353,45 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
4353 }),4353 }),
4354 );4354 );
43554355
4356 it.effect("persists and clears a project monogram", () =>
4357 Effect.gen(function* () {
4358 const engine = yield* OrchestrationEngineService;
4359 const sql = yield* SqlClient.SqlClient;
4360 const projectId = ProjectId.make("project-monogram");
4361 yield* engine.dispatch({
4362 type: "project.create",
4363 commandId: CommandId.make("cmd-monogram-create"),
4364 projectId,
4365 title: "Monogram",
4366 workspaceRoot: "/tmp/project-monogram",
4367 defaultModelSelection: null,
4368 createdAt: "2026-01-01T00:00:00.000Z",
4369 });
4370 yield* engine.dispatch({
4371 type: "project.meta.update",
4372 commandId: CommandId.make("cmd-monogram-save"),
4373 projectId,
4374 projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" },
4375 });
4376 const saved = yield* sql<{
4377 readonly icon: string | null;
4378 }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`;
4379 assert.deepEqual(saved, [
4380 { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' },
4381 ]);
4382 yield* engine.dispatch({
4383 type: "project.meta.update",
4384 commandId: CommandId.make("cmd-monogram-clear"),
4385 projectId,
4386 projectIcon: null,
4387 });
4388 const cleared = yield* sql<{
4389 readonly icon: string | null;
4390 }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`;
4391 assert.deepEqual(cleared, [{ icon: null }]);
4392 }),
4393 );
4394
4356 it.effect("re-creating a deleted thread id starts from an empty projection", () =>4395 it.effect("re-creating a deleted thread id starts from an empty projection", () =>
4357 Effect.gen(function* () {4396 Effect.gen(function* () {
4358 const engine = yield* OrchestrationEngineService;4397 const engine = yield* OrchestrationEngineService;
apps/web/src/components/ProjectFavicon.tsx+11-42
...@@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react";
11import { projectFaviconUrlAtom } from "../state/assets";11import { projectFaviconUrlAtom } from "../state/assets";
12import { deriveProjectIdentity } from "../projectIdentity";12import { deriveProjectIdentity } from "../projectIdentity";
13import { projectIconColorClassName } from "../projectIconColors";13import { projectIconColorClassName } from "../projectIconColors";
14import { ProjectMonogram } from "./ProjectMonogram";
14import { cn } from "~/lib/utils";15import { cn } from "~/lib/utils";
1516
16const DynamicIcon = lazy(() =>17const DynamicIcon = lazy(() =>
...@@ -42,6 +43,15 @@ export function ProjectFavicon(input: {...@@ -42,6 +43,15 @@ export function ProjectFavicon(input: {
42 faviconPath: project.faviconPath,43 faviconPath: project.faviconPath,
43 }),44 }),
44 );45 );
46 if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) {
47 return (
48 <ProjectMonogram
49 text={project.projectIcon.monogram}
50 color={project.projectIcon.color}
51 className={input.className}
52 />
53 );
54 }
45 if (project.projectIcon?.kind === "emoji") {55 if (project.projectIcon?.kind === "emoji") {
46 return (56 return (
47 <ProjectFaviconFallback57 <ProjectFaviconFallback
...@@ -112,49 +122,8 @@ function ProjectFaviconFallback({...@@ -112,49 +122,8 @@ function ProjectFaviconFallback({
112}) {122}) {
113 if (projectName && projectName.trim().length > 0) {123 if (projectName && projectName.trim().length > 0) {
114 const identity = deriveProjectIdentity(projectName);124 const identity = deriveProjectIdentity(projectName);
115 // Wrapped like the emoji and Lucide branches so the monogram sits where an
116 // <img> favicon would. Menu items, buttons and the like pull every bare svg
117 // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this
118 // tile has no such padding.
119 return (125 return (
120 <span126 <ProjectMonogram text={identity.monogram} color={identity.color} className={className} />
121 aria-hidden="true"
122 className={cn("inline-flex size-4 shrink-0 items-center justify-center", className)}
123 >
124 <svg
125 viewBox="0 0 16 16"
126 className="size-full overflow-hidden rounded-[25%] font-mono select-none"
127 style={{
128 backgroundColor: identity.background,
129 backgroundImage: `linear-gradient(145deg, ${identity.highlight}, ${identity.background} 72%)`,
130 }}
131 >
132 <text
133 x="8"
134 y="10.8"
135 textAnchor="middle"
136 fill="white"
137 className="font-mono"
138 fontSize="8.25"
139 fontWeight="700"
140 textLength="12"
141 lengthAdjust="spacingAndGlyphs"
142 textRendering="geometricPrecision"
143 >
144 {identity.monogram}
145 </text>
146 <rect
147 x="0.25"
148 y="0.25"
149 width="15.5"
150 height="15.5"
151 rx="3.75"
152 fill="none"
153 strokeWidth="0.5"
154 className="stroke-black/10 dark:stroke-white/10"
155 />
156 </svg>
157 </span>
158 );127 );
159 }128 }
160129
apps/web/src/components/ProjectMonogram.tsx created+52
...@@ -0,0 +1,52 @@
1import type { ProjectIconColor } from "@t3tools/contracts";
2import { projectIconColorClassName } from "../projectIconColors";
3import { cn } from "~/lib/utils";
4
5const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
6
7export function ProjectMonogram({
8 text,
9 color,
10 className,
11}: {
12 readonly text: string;
13 readonly color: ProjectIconColor;
14 readonly className?: string | undefined;
15}) {
16 // Wrapped like the emoji and Lucide branches so the monogram sits where an
17 // <img> favicon would. Menu items, buttons and the like pull every bare svg
18 // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this
19 // tile has no such padding.
20 return (
21 <span
22 aria-hidden="true"
23 className={cn("inline-flex size-4 shrink-0 items-center justify-center", className)}
24 >
25 <svg
26 viewBox="0 0 16 16"
27 className={cn(
28 "size-full overflow-hidden rounded-[25%] font-mono select-none",
29 projectIconColorClassName(color),
30 )}
31 style={{
32 backgroundColor: "color-mix(in srgb, currentColor 14%, transparent)",
33 }}
34 >
35 <text
36 x="8"
37 y="10.8"
38 textAnchor="middle"
39 fill="currentColor"
40 className="font-mono"
41 fontSize="8.25"
42 fontWeight="700"
43 textLength={Array.from(monogramSegmenter.segment(text)).length === 1 ? 6 : 12}
44 lengthAdjust="spacingAndGlyphs"
45 textRendering="geometricPrecision"
46 >
47 {text}
48 </text>
49 </svg>
50 </span>
51 );
52}
apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx+7-1
...@@ -41,7 +41,13 @@ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";...@@ -41,7 +41,13 @@ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";
41describe("ProjectIconPickerDialog", () => {41describe("ProjectIconPickerDialog", () => {
42 it("shows icons first and selects them for an automatic project", () => {42 it("shows icons first and selects them for an automatic project", () => {
43 const markup = renderToStaticMarkup(43 const markup = renderToStaticMarkup(
44 <ProjectIconPickerDialog current={null} open onOpenChange={() => {}} onSelect={() => {}} />,44 <ProjectIconPickerDialog
45 current={null}
46 projectName="Test"
47 open
48 onOpenChange={() => {}}
49 onSelect={() => {}}
50 />,
45 );51 );
4652
47 expect(markup).toContain('data-current="lucide"');53 expect(markup).toContain('data-current="lucide"');
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+87-32
...@@ -1,4 +1,11 @@...@@ -1,4 +1,11 @@
1import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";1import * as Schema from "effect/Schema";
2import { deriveProjectIdentity } from "../../projectIdentity";
3import { ProjectMonogram } from "../ProjectMonogram";
4import {
5 ProjectMonogramText,
6 type ProjectIconColor,
7 type ProjectIconOverride,
8} from "@t3tools/contracts";
2import { DynamicIcon, type IconName } from "lucide-react/dynamic";9import { DynamicIcon, type IconName } from "lucide-react/dynamic";
3import { useEffect, useMemo, useRef, useState } from "react";10import { useEffect, useMemo, useRef, useState } from "react";
4import {11import {
...@@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area";...@@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area";
24import { Toggle, ToggleGroup } from "../ui/toggle-group";31import { Toggle, ToggleGroup } from "../ui/toggle-group";
2532
26const DEFAULT_ICON: IconName = "folder-code";33const DEFAULT_ICON: IconName = "folder-code";
27const DEFAULT_COLOR: ProjectIconColor = "blue";34const isMonogramText = Schema.is(ProjectMonogramText);
2835
29function iconLabel(name: string): string {36function iconLabel(name: string): string {
30 return name37 return name
...@@ -35,23 +42,29 @@ function iconLabel(name: string): string {...@@ -35,23 +42,29 @@ function iconLabel(name: string): string {
3542
36export function ProjectIconPickerDialog({43export function ProjectIconPickerDialog({
37 current,44 current,
45 projectName,
38 open,46 open,
39 onOpenChange,47 onOpenChange,
40 onSelect,48 onSelect,
41}: {49}: {
42 readonly current: ProjectIconOverride | null;50 readonly current: ProjectIconOverride | null;
51 readonly projectName: string;
43 readonly open: boolean;52 readonly open: boolean;
44 readonly onOpenChange: (open: boolean) => void;53 readonly onOpenChange: (open: boolean) => void;
45 readonly onSelect: (icon: ProjectIconOverride) => void;54 readonly onSelect: (icon: ProjectIconOverride) => void;
46}) {55}) {
47 const [mode, setMode] = useState<"lucide" | "emoji">(56 const automatic = deriveProjectIdentity(projectName);
48 current?.kind === "emoji" ? "emoji" : "lucide",57 const [mode, setMode] = useState<ProjectIconOverride["kind"] | "monogram">(
58 current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
49 );59 );
50 const [iconName, setIconName] = useState<IconName>(60 const [iconName, setIconName] = useState<IconName>(
51 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,61 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
52 );62 );
53 const [color, setColor] = useState<ProjectIconColor>(63 const [color, setColor] = useState<ProjectIconColor>(
54 current?.kind === "lucide" ? current.color : DEFAULT_COLOR,64 current && current.kind !== "emoji" ? current.color : automatic.color,
65 );
66 const [letters, setLetters] = useState(
67 current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,
55 );68 );
56 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");69 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");
57 const [query, setQuery] = useState("");70 const [query, setQuery] = useState("");
...@@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({...@@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({
6073
61 useEffect(() => {74 useEffect(() => {
62 if (open && !previousOpenRef.current) {75 if (open && !previousOpenRef.current) {
63 setMode(current?.kind === "emoji" ? "emoji" : "lucide");76 setMode(
77 current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
78 );
64 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);79 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
65 setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR);80 setColor(current && current.kind !== "emoji" ? current.color : automatic.color);
81 setLetters(
82 current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,
83 );
66 setEmoji(current?.kind === "emoji" ? current.emoji : "💻");84 setEmoji(current?.kind === "emoji" ? current.emoji : "💻");
67 setQuery("");85 setQuery("");
68 setCustomEmoji("");86 setCustomEmoji("");
69 }87 }
70 previousOpenRef.current = open;88 previousOpenRef.current = open;
71 }, [current, open]);89 }, [current, open, automatic.color, automatic.monogram]);
7290
73 const icons = useMemo(() => filterProjectIconNames(query), [query]);91 const icons = useMemo(() => filterProjectIconNames(query), [query]);
74 const selectedColorClassName = projectIconColorClassName(color);92 const selectedColorClassName = projectIconColorClassName(color);
93 const monogram = letters.normalize("NFKC").trim().toUpperCase();
94 const validMonogram = isMonogramText(monogram);
75 const save = () => {95 const save = () => {
96 if (mode === "monogram" && !validMonogram) return;
76 onSelect(97 onSelect(
77 mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji },98 mode === "monogram"
99 ? { kind: "lucide", name: DEFAULT_ICON, monogram, color }
100 : mode === "lucide"
101 ? { kind: "lucide", name: iconName, color }
102 : { kind: "emoji", emoji },
78 );103 );
79 onOpenChange(false);104 onOpenChange(false);
80 };105 };
...@@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({...@@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({
84 <DialogPopup className="w-full sm:w-[32rem]">109 <DialogPopup className="w-full sm:w-[32rem]">
85 <DialogHeader>110 <DialogHeader>
86 <DialogTitle>Choose project icon</DialogTitle>111 <DialogTitle>Choose project icon</DialogTitle>
87 <DialogDescription>Pick any Lucide icon and color, or use an emoji.</DialogDescription>112 <DialogDescription>Choose an icon, emoji, or monogram.</DialogDescription>
88 </DialogHeader>113 </DialogHeader>
89 <DialogPanel className="flex min-h-0 flex-col gap-4">114 <DialogPanel className="flex min-h-0 flex-col gap-4">
90 <ToggleGroup115 <ToggleGroup
...@@ -93,35 +118,39 @@ export function ProjectIconPickerDialog({...@@ -93,35 +118,39 @@ export function ProjectIconPickerDialog({
93 value={[mode]}118 value={[mode]}
94 onValueChange={(next) => {119 onValueChange={(next) => {
95 const value = next[0];120 const value = next[0];
96 if (value === "lucide" || value === "emoji") setMode(value);121 if (value === "lucide" || value === "emoji" || value === "monogram") setMode(value);
97 }}122 }}
98 >123 >
99 <Toggle value="lucide">Icons</Toggle>124 <Toggle value="lucide">Icons</Toggle>
100 <Toggle value="emoji">Emoji</Toggle>125 <Toggle value="emoji">Emoji</Toggle>
126 <Toggle value="monogram">Monogram</Toggle>
101 </ToggleGroup>127 </ToggleGroup>
102128
129 {mode !== "emoji" ? (
130 <div>
131 <div className="mb-2 text-xs font-medium text-muted-foreground">Color</div>
132 <div className="flex flex-wrap gap-1.5" role="group" aria-label="Icon color">
133 {PROJECT_ICON_COLORS.map((option) => (
134 <button
135 key={option.value}
136 type="button"
137 aria-label={option.label}
138 aria-pressed={color === option.value}
139 className={cn(
140 "flex size-6 items-center justify-center rounded-full border border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring",
141 color === option.value && "border-foreground/64",
142 )}
143 onClick={() => setColor(option.value)}
144 >
145 <span className={cn("size-4 rounded-full", option.swatchClassName)} />
146 </button>
147 ))}
148 </div>
149 </div>
150 ) : null}
151
103 {mode === "lucide" ? (152 {mode === "lucide" ? (
104 <>153 <>
105 <div>
106 <div className="mb-2 text-xs font-medium text-muted-foreground">Color</div>
107 <div className="flex flex-wrap gap-1.5" role="group" aria-label="Icon color">
108 {PROJECT_ICON_COLORS.map((option) => (
109 <button
110 key={option.value}
111 type="button"
112 aria-label={option.label}
113 aria-pressed={color === option.value}
114 className={cn(
115 "flex size-6 items-center justify-center rounded-full border border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring",
116 color === option.value && "border-foreground/64",
117 )}
118 onClick={() => setColor(option.value)}
119 >
120 <span className={cn("size-4 rounded-full", option.swatchClassName)} />
121 </button>
122 ))}
123 </div>
124 </div>
125 <Input154 <Input
126 type="search"155 type="search"
127 value={query}156 value={query}
...@@ -153,6 +182,30 @@ export function ProjectIconPickerDialog({...@@ -153,6 +182,30 @@ export function ProjectIconPickerDialog({
153 <p className="py-8 text-center text-sm text-muted-foreground">No icons found.</p>182 <p className="py-8 text-center text-sm text-muted-foreground">No icons found.</p>
154 ) : null}183 ) : null}
155 </>184 </>
185 ) : mode === "monogram" ? (
186 <div className="flex items-center gap-4 py-2">
187 <ProjectMonogram
188 text={validMonogram ? monogram : automatic.monogram}
189 color={color}
190 className="size-12"
191 />
192 <div className="flex-1 space-y-2">
193 <label htmlFor="project-monogram" className="text-sm font-medium">
194 Letters
195 </label>
196 <Input
197 id="project-monogram"
198 value={letters}
199 onChange={(event) => setLetters(event.currentTarget.value)}
200 aria-describedby="project-monogram-hint"
201 aria-invalid={!validMonogram}
202 autoComplete="off"
203 />
204 <p id="project-monogram-hint" className="text-xs text-muted-foreground">
205 One or two letters or numbers.
206 </p>
207 </div>
208 </div>
156 ) : (209 ) : (
157 <>210 <>
158 <ScrollArea scrollFade className="max-h-64">211 <ScrollArea scrollFade className="max-h-64">
...@@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({...@@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({
197 <Button variant="outline" onClick={() => onOpenChange(false)}>250 <Button variant="outline" onClick={() => onOpenChange(false)}>
198 Cancel251 Cancel
199 </Button>252 </Button>
200 <Button onClick={save}>Save icon</Button>253 <Button onClick={save} disabled={mode === "monogram" && !validMonogram}>
254 Save icon
255 </Button>
201 </DialogFooter>256 </DialogFooter>
202 </DialogPopup>257 </DialogPopup>
203 </Dialog>258 </Dialog>
apps/web/src/components/settings/ProjectSettingsPanel.tsx+2-1
...@@ -435,7 +435,7 @@ function ProjectDetail({...@@ -435,7 +435,7 @@ function ProjectDetail({
435 title="Project icon"435 title="Project icon"
436 description={436 description={
437 projectIcon?.kind === "lucide"437 projectIcon?.kind === "lucide"
438 ? `${projectIcon.name} · ${projectIcon.color}`438 ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}`
439 : projectIcon?.kind === "emoji"439 : projectIcon?.kind === "emoji"
440 ? projectIcon.emoji440 ? projectIcon.emoji
441 : (faviconPath ?? "Automatic")441 : (faviconPath ?? "Automatic")
...@@ -530,6 +530,7 @@ function ProjectDetail({...@@ -530,6 +530,7 @@ function ProjectDetail({
530 <Suspense fallback={null}>530 <Suspense fallback={null}>
531 <ProjectIconPickerDialog531 <ProjectIconPickerDialog
532 current={projectIcon}532 current={projectIcon}
533 projectName={representative.title}
533 open534 open
534 onOpenChange={setIconPickerOpen}535 onOpenChange={setIconPickerOpen}
535 onSelect={(icon) => void setProjectIcon({ faviconPath: null, projectIcon: icon })}536 onSelect={(icon) => void setProjectIcon({ faviconPath: null, projectIcon: icon })}
apps/web/src/projectIdentity.test.ts+10-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { PROJECT_ICON_COLORS } from "./projectIconColors";
2import { deriveProjectIdentity } from "./projectIdentity";3import { deriveProjectIdentity } from "./projectIdentity";
34
4describe("deriveProjectIdentity", () => {5describe("deriveProjectIdentity", () => {
...@@ -18,14 +19,20 @@ describe("deriveProjectIdentity", () => {...@@ -18,14 +19,20 @@ describe("deriveProjectIdentity", () => {
18 const canonical = deriveProjectIdentity("Nebula");19 const canonical = deriveProjectIdentity("Nebula");
19 const equivalent = deriveProjectIdentity(" NEBULA ");20 const equivalent = deriveProjectIdentity(" NEBULA ");
2021
21 expect(equivalent.background).toBe(canonical.background);22 expect(equivalent.color).toBe(canonical.color);
22 expect(equivalent.highlight).toBe(canonical.highlight);23 });
24
25 it("uses only colors available in the icon picker", () => {
26 const palette = PROJECT_ICON_COLORS.map(({ value }) => value);
27 for (const name of ["Jobs", "Scripts and Extractors", "T3", "文書", "", "---"]) {
28 expect(palette).toContain(deriveProjectIdentity(name).color);
29 }
23 });30 });
2431
25 it("generates different hues for different project names", () => {32 it("generates different hues for different project names", () => {
26 const colors = new Set(33 const colors = new Set(
27 ["Nebula", "M7 Forge", "Silver Orchard", "Blue Harbor", "Copper Finch", "Juniper Vale"].map(34 ["Nebula", "M7 Forge", "Silver Orchard", "Blue Harbor", "Copper Finch", "Juniper Vale"].map(
28 (projectName) => deriveProjectIdentity(projectName).background,35 (projectName) => deriveProjectIdentity(projectName).color,
29 ),36 ),
30 );37 );
3138
apps/web/src/projectIdentity.ts+9-9
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { PROJECT_ICON_COLORS } from "./projectIconColors";
2import type { ProjectIconColor } from "@t3tools/contracts";
3
1/** Visual identity tokens for a generated project badge. */4/** Visual identity tokens for a generated project badge. */
2export interface ProjectIdentity {5export interface ProjectIdentity {
3 readonly monogram: string;6 readonly monogram: string;
4 readonly background: string;7 readonly color: ProjectIconColor;
5 readonly highlight: string;
6}8}
79
8function normalizeProjectName(projectName: string): string {10function normalizeProjectName(projectName: string): string {
...@@ -23,21 +25,19 @@ function projectMonogram(projectName: string): string {...@@ -23,21 +25,19 @@ function projectMonogram(projectName: string): string {
23 return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");25 return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");
24}26}
2527
26function projectHue(projectName: string): number {28function projectColor(projectName: string): ProjectIconColor {
27 const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project";29 const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project";
28 let hue = 0;30 let index = 0;
29 for (const glyph of seed) {31 for (const glyph of seed) {
30 hue = (hue * 31 + (glyph.codePointAt(0) ?? 0)) % 360;32 index = (index * 31 + (glyph.codePointAt(0) ?? 0)) % PROJECT_ICON_COLORS.length;
31 }33 }
32 return hue;34 return PROJECT_ICON_COLORS[index]?.value ?? "blue";
33}35}
3436
35/** Derives the stable monogram and generated colors used when a project has no icon. */37/** Derives the stable monogram and generated colors used when a project has no icon. */
36export function deriveProjectIdentity(projectName: string): ProjectIdentity {38export function deriveProjectIdentity(projectName: string): ProjectIdentity {
37 const hue = projectHue(projectName);
38 return {39 return {
39 monogram: projectMonogram(projectName),40 monogram: projectMonogram(projectName),
40 background: `hsl(${hue} 48% 36%)`,41 color: projectColor(projectName),
41 highlight: `hsl(${(hue + 24) % 360} 58% 48%)`,
42 };42 };
43}43}
docs/user/project-settings.md+5-3
...@@ -43,12 +43,14 @@ Browser access changes apply when an agent session next starts....@@ -43,12 +43,14 @@ Browser access changes apply when an agent session next starts.
4343
44## Project icons44## Project icons
4545
46Select the project and open Project to choose an icon, emoji, or image. The choice applies to46Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to
47every checkout in the project group and appears on connected clients. Choose **Automatic** to let47every checkout in the project group and appears on connected clients. Choose **Automatic** to let
48T3 Code detect an icon again.48T3 Code detect an icon again.
4949
50When no image is found, web and desktop show a two-character monogram with colors50Choose **Monogram** in the icon picker to set one or two letters or numbers and a color.
51derived from the saved project name. For example, `Nebula` becomes `NA`,51
52When no image is found, web and desktop show a two-character monogram with a color
53from the icon palette, derived from the saved project name. For example, `Nebula` becomes `NA`,
52`Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`.54`Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`.
5355
54## Keep the default branch current56## Keep the default branch current
packages/contracts/src/orchestration.test.ts+72
...@@ -17,6 +17,8 @@ import {...@@ -17,6 +17,8 @@ import {
17 OrchestrationGetTurnDiffInput,17 OrchestrationGetTurnDiffInput,
18 OrchestrationLatestTurn,18 OrchestrationLatestTurn,
19 ProjectCreatedPayload,19 ProjectCreatedPayload,
20 OrchestrationProjectShell,
21 ProjectIconColor,
20 ProjectMetaUpdatedPayload,22 ProjectMetaUpdatedPayload,
21 OrchestrationProposedPlan,23 OrchestrationProposedPlan,
22 OrchestrationSession,24 OrchestrationSession,
...@@ -40,6 +42,18 @@ import { ProviderInstanceId } from "./providerInstance.ts";...@@ -40,6 +42,18 @@ import { ProviderInstanceId } from "./providerInstance.ts";
40const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);42const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
41const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);43const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);
42const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);44const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
45// The icon shape understood by clients released before monograms.
46const legacyProjectIcon = Schema.Union([
47 Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: ProjectIconColor }),
48 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
49]);
50const decodeLegacyProjectShell = Schema.decodeUnknownEffect(
51 Schema.Struct({
52 ...OrchestrationProjectShell.fields,
53 projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)),
54 }),
55);
56const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);
43const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);57const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
44const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);58const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
45const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);59const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
...@@ -1494,6 +1508,64 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>...@@ -1494,6 +1508,64 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
1494 }),1508 }),
1495);1509);
14961510
1511it.effect("older clients decode monogram projects as their fallback icon", () =>
1512 Effect.gen(function* () {
1513 const encoded = yield* encodeProjectShell({
1514 id: ProjectId.make("project-monogram"),
1515 title: "Monogram",
1516 workspaceRoot: "/tmp/monogram",
1517 defaultModelSelection: null,
1518 scripts: [],
1519 createdAt: "2026-01-01T00:00:00.000Z",
1520 updatedAt: "2026-01-01T00:00:00.000Z",
1521 projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" },
1522 });
1523 const decoded = yield* decodeLegacyProjectShell(encoded);
1524 assert.deepEqual(decoded.projectIcon, { kind: "lucide", name: "folder-code", color: "violet" });
1525 }),
1526);
1527
1528it.effect("project monograms validate text and palette colors", () =>
1529 Effect.gen(function* () {
1530 for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) {
1531 const projectIcon = {
1532 kind: "lucide",
1533 name: "folder-code",
1534 color: "violet",
1535 monogram: text,
1536 } as const;
1537 const command = yield* decodeOrchestrationCommand({
1538 type: "project.meta.update",
1539 commandId: "cmd-monogram",
1540 projectId: "project-1",
1541 projectIcon,
1542 });
1543 assert.strictEqual(command.type, "project.meta.update");
1544 if (command.type === "project.meta.update")
1545 assert.deepEqual(command.projectIcon, projectIcon);
1546 }
1547 for (const projectIcon of [
1548 { kind: "lucide", name: "folder-code", monogram: "", color: "blue" },
1549 { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" },
1550 { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" },
1551 { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" },
1552 { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" },
1553 { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" },
1554 { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" },
1555 ]) {
1556 const result = yield* Effect.exit(
1557 decodeOrchestrationCommand({
1558 type: "project.meta.update",
1559 commandId: "cmd-monogram-invalid",
1560 projectId: "project-1",
1561 projectIcon,
1562 }),
1563 );
1564 assert.strictEqual(result._tag, "Failure");
1565 }
1566 }),
1567);
1568
1497it.effect("rejects thread history imports without messages", () =>1569it.effect("rejects thread history imports without messages", () =>
1498 Effect.gen(function* () {1570 Effect.gen(function* () {
1499 const result = yield* Effect.exit(1571 const result = yield* Effect.exit(
packages/contracts/src/orchestration.ts+9
...@@ -459,11 +459,20 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check(...@@ -459,11 +459,20 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check(
459459
460const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));460const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
461461
462const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
463export const ProjectMonogramText = TrimmedNonEmptyString.check(
464 Schema.isMaxLength(32),
465 Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
466 Schema.makeFilter((text) => Array.from(monogramSegmenter.segment(text)).length <= 2),
467);
468
462export const ProjectIconOverride = Schema.Union([469export const ProjectIconOverride = Schema.Union([
463 Schema.Struct({470 Schema.Struct({
464 kind: Schema.Literal("lucide"),471 kind: Schema.Literal("lucide"),
465 name: ProjectLucideIconName,472 name: ProjectLucideIconName,
466 color: ProjectIconColor,473 color: ProjectIconColor,
474 // Older clients ignore this field and render the named Lucide icon instead.
475 monogram: Schema.optional(ProjectMonogramText),
467 }),476 }),
468 Schema.Struct({477 Schema.Struct({
469 kind: Schema.Literal("emoji"),478 kind: Schema.Literal("emoji"),