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) => {
43534353 }),
43544354 );
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
43564395 it.effect("re-creating a deleted thread id starts from an empty projection", () =>
43574396 Effect.gen(function* () {
43584397 const engine = yield* OrchestrationEngineService;
apps/web/src/components/ProjectFavicon.tsx+11-42
......@@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react";
1111import { projectFaviconUrlAtom } from "../state/assets";
1212import { deriveProjectIdentity } from "../projectIdentity";
1313import { projectIconColorClassName } from "../projectIconColors";
14import { ProjectMonogram } from "./ProjectMonogram";
1415import { cn } from "~/lib/utils";
1516
1617const DynamicIcon = lazy(() =>
......@@ -42,6 +43,15 @@ export function ProjectFavicon(input: {
4243 faviconPath: project.faviconPath,
4344 }),
4445 );
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 }
4555 if (project.projectIcon?.kind === "emoji") {
4656 return (
4757 <ProjectFaviconFallback
......@@ -112,49 +122,8 @@ function ProjectFaviconFallback({
112122}) {
113123 if (projectName && projectName.trim().length > 0) {
114124 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.
119125 return (
120 <span
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>
126 <ProjectMonogram text={identity.monogram} color={identity.color} className={className} />
158127 );
159128 }
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";
4141describe("ProjectIconPickerDialog", () => {
4242 it("shows icons first and selects them for an automatic project", () => {
4343 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 />,
4551 );
4652
4753 expect(markup).toContain('data-current="lucide"');
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+87-32
......@@ -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";
29import { DynamicIcon, type IconName } from "lucide-react/dynamic";
310import { useEffect, useMemo, useRef, useState } from "react";
411import {
......@@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area";
2431import { Toggle, ToggleGroup } from "../ui/toggle-group";
2532
2633const DEFAULT_ICON: IconName = "folder-code";
27const DEFAULT_COLOR: ProjectIconColor = "blue";
34const isMonogramText = Schema.is(ProjectMonogramText);
2835
2936function iconLabel(name: string): string {
3037 return name
......@@ -35,23 +42,29 @@ function iconLabel(name: string): string {
3542
3643export function ProjectIconPickerDialog({
3744 current,
45 projectName,
3846 open,
3947 onOpenChange,
4048 onSelect,
4149}: {
4250 readonly current: ProjectIconOverride | null;
51 readonly projectName: string;
4352 readonly open: boolean;
4453 readonly onOpenChange: (open: boolean) => void;
4554 readonly onSelect: (icon: ProjectIconOverride) => void;
4655}) {
47 const [mode, setMode] = useState<"lucide" | "emoji">(
48 current?.kind === "emoji" ? "emoji" : "lucide",
56 const automatic = deriveProjectIdentity(projectName);
57 const [mode, setMode] = useState<ProjectIconOverride["kind"] | "monogram">(
58 current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
4959 );
5060 const [iconName, setIconName] = useState<IconName>(
5161 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
5262 );
5363 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,
5568 );
5669 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");
5770 const [query, setQuery] = useState("");
......@@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({
6073
6174 useEffect(() => {
6275 if (open && !previousOpenRef.current) {
63 setMode(current?.kind === "emoji" ? "emoji" : "lucide");
76 setMode(
77 current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
78 );
6479 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 );
6684 setEmoji(current?.kind === "emoji" ? current.emoji : "💻");
6785 setQuery("");
6886 setCustomEmoji("");
6987 }
7088 previousOpenRef.current = open;
71 }, [current, open]);
89 }, [current, open, automatic.color, automatic.monogram]);
7290
7391 const icons = useMemo(() => filterProjectIconNames(query), [query]);
7492 const selectedColorClassName = projectIconColorClassName(color);
93 const monogram = letters.normalize("NFKC").trim().toUpperCase();
94 const validMonogram = isMonogramText(monogram);
7595 const save = () => {
96 if (mode === "monogram" && !validMonogram) return;
7697 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 },
78103 );
79104 onOpenChange(false);
80105 };
......@@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({
84109 <DialogPopup className="w-full sm:w-[32rem]">
85110 <DialogHeader>
86111 <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>
88113 </DialogHeader>
89114 <DialogPanel className="flex min-h-0 flex-col gap-4">
90115 <ToggleGroup
......@@ -93,35 +118,39 @@ export function ProjectIconPickerDialog({
93118 value={[mode]}
94119 onValueChange={(next) => {
95120 const value = next[0];
96 if (value === "lucide" || value === "emoji") setMode(value);
121 if (value === "lucide" || value === "emoji" || value === "monogram") setMode(value);
97122 }}
98123 >
99124 <Toggle value="lucide">Icons</Toggle>
100125 <Toggle value="emoji">Emoji</Toggle>
126 <Toggle value="monogram">Monogram</Toggle>
101127 </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
103152 {mode === "lucide" ? (
104153 <>
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>
125154 <Input
126155 type="search"
127156 value={query}
......@@ -153,6 +182,30 @@ export function ProjectIconPickerDialog({
153182 <p className="py-8 text-center text-sm text-muted-foreground">No icons found.</p>
154183 ) : null}
155184 </>
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>
156209 ) : (
157210 <>
158211 <ScrollArea scrollFade className="max-h-64">
......@@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({
197250 <Button variant="outline" onClick={() => onOpenChange(false)}>
198251 Cancel
199252 </Button>
200 <Button onClick={save}>Save icon</Button>
253 <Button onClick={save} disabled={mode === "monogram" && !validMonogram}>
254 Save icon
255 </Button>
201256 </DialogFooter>
202257 </DialogPopup>
203258 </Dialog>
apps/web/src/components/settings/ProjectSettingsPanel.tsx+2-1
......@@ -435,7 +435,7 @@ function ProjectDetail({
435435 title="Project icon"
436436 description={
437437 projectIcon?.kind === "lucide"
438 ? `${projectIcon.name} · ${projectIcon.color}`
438 ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}`
439439 : projectIcon?.kind === "emoji"
440440 ? projectIcon.emoji
441441 : (faviconPath ?? "Automatic")
......@@ -530,6 +530,7 @@ function ProjectDetail({
530530 <Suspense fallback={null}>
531531 <ProjectIconPickerDialog
532532 current={projectIcon}
533 projectName={representative.title}
533534 open
534535 onOpenChange={setIconPickerOpen}
535536 onSelect={(icon) => void setProjectIcon({ faviconPath: null, projectIcon: icon })}
apps/web/src/projectIdentity.test.ts+10-3
......@@ -1,4 +1,5 @@
11import { describe, expect, it } from "vite-plus/test";
2import { PROJECT_ICON_COLORS } from "./projectIconColors";
23import { deriveProjectIdentity } from "./projectIdentity";
34
45describe("deriveProjectIdentity", () => {
......@@ -18,14 +19,20 @@ describe("deriveProjectIdentity", () => {
1819 const canonical = deriveProjectIdentity("Nebula");
1920 const equivalent = deriveProjectIdentity(" NEBULA ");
2021
21 expect(equivalent.background).toBe(canonical.background);
22 expect(equivalent.highlight).toBe(canonical.highlight);
22 expect(equivalent.color).toBe(canonical.color);
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 }
2330 });
2431
2532 it("generates different hues for different project names", () => {
2633 const colors = new Set(
2734 ["Nebula", "M7 Forge", "Silver Orchard", "Blue Harbor", "Copper Finch", "Juniper Vale"].map(
28 (projectName) => deriveProjectIdentity(projectName).background,
35 (projectName) => deriveProjectIdentity(projectName).color,
2936 ),
3037 );
3138
apps/web/src/projectIdentity.ts+9-9
......@@ -1,8 +1,10 @@
1import { PROJECT_ICON_COLORS } from "./projectIconColors";
2import type { ProjectIconColor } from "@t3tools/contracts";
3
14/** Visual identity tokens for a generated project badge. */
25export interface ProjectIdentity {
36 readonly monogram: string;
4 readonly background: string;
5 readonly highlight: string;
7 readonly color: ProjectIconColor;
68}
79
810function normalizeProjectName(projectName: string): string {
......@@ -23,21 +25,19 @@ function projectMonogram(projectName: string): string {
2325 return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");
2426}
2527
26function projectHue(projectName: string): number {
28function projectColor(projectName: string): ProjectIconColor {
2729 const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project";
28 let hue = 0;
30 let index = 0;
2931 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;
3133 }
32 return hue;
34 return PROJECT_ICON_COLORS[index]?.value ?? "blue";
3335}
3436
3537/** Derives the stable monogram and generated colors used when a project has no icon. */
3638export function deriveProjectIdentity(projectName: string): ProjectIdentity {
37 const hue = projectHue(projectName);
3839 return {
3940 monogram: projectMonogram(projectName),
40 background: `hsl(${hue} 48% 36%)`,
41 highlight: `hsl(${(hue + 24) % 360} 58% 48%)`,
41 color: projectColor(projectName),
4242 };
4343}
docs/user/project-settings.md+5-3
......@@ -43,12 +43,14 @@ Browser access changes apply when an agent session next starts.
4343
4444## Project icons
4545
46Select the project and open Project to choose an icon, emoji, or image. The choice applies to
46Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to
4747every checkout in the project group and appears on connected clients. Choose **Automatic** to let
4848T3 Code detect an icon again.
4949
50When no image is found, web and desktop show a two-character monogram with colors
51derived from the saved project name. For example, `Nebula` becomes `NA`,
50Choose **Monogram** in the icon picker to set one or two letters or numbers and a color.
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`,
5254`Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`.
5355
5456## Keep the default branch current
packages/contracts/src/orchestration.test.ts+72
......@@ -17,6 +17,8 @@ import {
1717 OrchestrationGetTurnDiffInput,
1818 OrchestrationLatestTurn,
1919 ProjectCreatedPayload,
20 OrchestrationProjectShell,
21 ProjectIconColor,
2022 ProjectMetaUpdatedPayload,
2123 OrchestrationProposedPlan,
2224 OrchestrationSession,
......@@ -40,6 +42,18 @@ import { ProviderInstanceId } from "./providerInstance.ts";
4042const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
4143const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);
4244const 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);
4357const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
4458const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
4559const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
......@@ -1494,6 +1508,64 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
14941508 }),
14951509);
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
14971569it.effect("rejects thread history imports without messages", () =>
14981570 Effect.gen(function* () {
14991571 const result = yield* Effect.exit(
packages/contracts/src/orchestration.ts+9
......@@ -459,11 +459,20 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check(
459459
460460const 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
462469export const ProjectIconOverride = Schema.Union([
463470 Schema.Struct({
464471 kind: Schema.Literal("lucide"),
465472 name: ProjectLucideIconName,
466473 color: ProjectIconColor,
474 // Older clients ignore this field and render the named Lucide icon instead.
475 monogram: Schema.optional(ProjectMonogramText),
467476 }),
468477 Schema.Struct({
469478 kind: Schema.Literal("emoji"),