From 0941caf5476b16d0c40ae2fe8643b4d4a9b25a62 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Mon, 14 Sep 2026 19:30:20 -0700 Subject: [PATCH 1/4] fix(web): soften project monogram icons --- apps/web/src/components/ProjectFavicon.tsx | 16 +++------------- apps/web/src/projectIdentity.test.ts | 5 ++--- apps/web/src/projectIdentity.ts | 6 ++---- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 17006889fb9c..3530517a562b 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -125,15 +125,15 @@ function ProjectFaviconFallback({ viewBox="0 0 16 16" className="size-full overflow-hidden rounded-[25%] font-mono select-none" style={{ - backgroundColor: identity.background, - backgroundImage: `linear-gradient(145deg, ${identity.highlight}, ${identity.background} 72%)`, + color: identity.color, + backgroundColor: "color-mix(in srgb, currentColor 14%, transparent)", }} > {identity.monogram} - ); diff --git a/apps/web/src/projectIdentity.test.ts b/apps/web/src/projectIdentity.test.ts index 942e76fc91b8..eb988842cf20 100644 --- a/apps/web/src/projectIdentity.test.ts +++ b/apps/web/src/projectIdentity.test.ts @@ -18,14 +18,13 @@ 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("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..7ae251e7c44f 100644 --- a/apps/web/src/projectIdentity.ts +++ b/apps/web/src/projectIdentity.ts @@ -1,8 +1,7 @@ /** Visual identity tokens for a generated project badge. */ export interface ProjectIdentity { readonly monogram: string; - readonly background: string; - readonly highlight: string; + readonly color: string; } function normalizeProjectName(projectName: string): string { @@ -37,7 +36,6 @@ 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: `light-dark(hsl(${hue} 65% 35%), hsl(${hue} 65% 70%))`, }; } From 8456478db427ba5946a5e1da3e6c1b514a61b150 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Mon, 14 Sep 2026 19:46:08 -0700 Subject: [PATCH 2/4] feat: add project monogram selection with the shared icon palette --- apps/mobile/global.css | 1 + apps/mobile/src/components/ProjectFavicon.tsx | 38 +++++- .../archive/ArchivedThreadsScreen.tsx | 1 + .../features/threads/NewTaskRouteScreen.tsx | 1 + .../features/threads/thread-list-items.tsx | 1 + .../features/threads/thread-list-v2-items.tsx | 3 + .../Layers/ProjectionPipeline.test.ts | 37 ++++++ apps/web/src/components/ProjectFavicon.tsx | 45 ++----- apps/web/src/components/ProjectMonogram.tsx | 50 ++++++++ .../settings/ProjectIconPickerDialog.test.tsx | 8 +- .../settings/ProjectIconPickerDialog.tsx | 115 +++++++++++++----- .../settings/ProjectSettingsPanel.tsx | 13 +- apps/web/src/index.css | 1 + apps/web/src/projectIconOptions.ts | 2 +- apps/web/src/projectIdentity.test.ts | 8 ++ apps/web/src/projectIdentity.ts | 16 +-- docs/user/project-settings.md | 8 +- packages/contracts/src/orchestration.test.ts | 34 ++++++ packages/contracts/src/orchestration.ts | 9 ++ packages/shared/package.json | 4 + .../shared}/src/projectIconColors.ts | 0 21 files changed, 310 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/components/ProjectMonogram.tsx rename {apps/web => packages/shared}/src/projectIconColors.ts (100%) diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 7a401fd7289d..21e87ba9f9e9 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "uniwind"; +@source "../../packages/shared/src/projectIconColors.ts"; @import "./generated-uniwind-themes.css"; /* ─── Theme tokens ──────────────────────────────────────────────────── */ diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f20..585c5ba74d36 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,8 +1,11 @@ +import Svg, { Rect, Text as SvgText } from "react-native-svg"; +import { useResolveClassNames } from "uniwind"; +import { projectIconColorClassName } from "@t3tools/shared/projectIconColors"; import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; -import { View } from "react-native"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { StyleSheet, View } from "react-native"; +import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, @@ -30,6 +33,7 @@ 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 faviconUrl = useAtomValue( @@ -51,6 +55,10 @@ export function ProjectFavicon(props: { : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; + if (props.projectIcon?.kind === "monogram") { + return ; + } + return ( ); } + +function ProjectMonogram({ + icon, + size, +}: { + readonly icon: Extract; + readonly size: number; +}) { + const { color } = StyleSheet.flatten(useResolveClassNames(projectIconColorClassName(icon.color))); + return ( + + + + {icon.text} + + + ); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 97f9c5b69d0f..6f6ed83fe505 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -375,6 +375,7 @@ function ProjectGroupLabel(props: { { }), ); + 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: "monogram", text: "T3", color: "violet" }, + }); + 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":"monogram","text":"T3","color":"violet"}' }]); + 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 3530517a562b..7834f489fc0f 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -10,7 +10,8 @@ import { lazy, Suspense, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; import { deriveProjectIdentity } from "../projectIdentity"; -import { projectIconColorClassName } from "../projectIconColors"; +import { projectIconColorClassName } from "@t3tools/shared/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 === "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..0dfa6d8bfff4 --- /dev/null +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -0,0 +1,50 @@ +import type { ProjectIconColor } from "@t3tools/contracts"; +import { projectIconColorClassName } from "@t3tools/shared/projectIconColors"; +import { cn } from "~/lib/utils"; + +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..f2474e531c07 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,27 @@ 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"); 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 === "monogram" ? current.text : automatic.monogram, ); const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); const [query, setQuery] = useState(""); @@ -60,21 +71,29 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind === "emoji" ? "emoji" : "lucide"); + setMode(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 === "monogram" ? current.text : 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: "monogram", text: monogram, color } + : mode === "lucide" + ? { kind: "lucide", name: iconName, color } + : { kind: "emoji", emoji }, ); onOpenChange(false); }; @@ -84,7 +103,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 +244,9 @@ export function ProjectIconPickerDialog({ - +
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 1a0f31d77836..d16fa20b5a05 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -434,11 +434,13 @@ function ProjectDetail({ void setProjectIcon({ faviconPath: null, projectIcon: icon })} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e7c5d919a211..867b56548e17 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@source "../../../packages/shared/src/projectIconColors.ts"; @custom-variant dark (&:is(.dark, .dark *)); @custom-variant light (&:not(.dark, .dark *)); diff --git a/apps/web/src/projectIconOptions.ts b/apps/web/src/projectIconOptions.ts index a2213fdd4bd2..ceab7c85c55b 100644 --- a/apps/web/src/projectIconOptions.ts +++ b/apps/web/src/projectIconOptions.ts @@ -1,5 +1,5 @@ import { iconNames, type IconName } from "lucide-react/dynamic"; -export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors"; +export { PROJECT_ICON_COLORS, projectIconColorClassName } from "@t3tools/shared/projectIconColors"; const POPULAR_PROJECT_ICONS = [ "folder-code", diff --git a/apps/web/src/projectIdentity.test.ts b/apps/web/src/projectIdentity.test.ts index eb988842cf20..9c23b4b5ae0e 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 "@t3tools/shared/projectIconColors"; import { deriveProjectIdentity } from "./projectIdentity"; describe("deriveProjectIdentity", () => { @@ -21,6 +22,13 @@ describe("deriveProjectIdentity", () => { 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( diff --git a/apps/web/src/projectIdentity.ts b/apps/web/src/projectIdentity.ts index 7ae251e7c44f..95b02225e0ba 100644 --- a/apps/web/src/projectIdentity.ts +++ b/apps/web/src/projectIdentity.ts @@ -1,7 +1,10 @@ +import { PROJECT_ICON_COLORS } from "@t3tools/shared/projectIconColors"; +import type { ProjectIconColor } from "@t3tools/contracts"; + /** Visual identity tokens for a generated project badge. */ export interface ProjectIdentity { readonly monogram: string; - readonly color: string; + readonly color: ProjectIconColor; } function normalizeProjectName(projectName: string): string { @@ -22,20 +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), - color: `light-dark(hsl(${hue} 65% 35%), hsl(${hue} 65% 70%))`, + 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..50f90ed86308 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1494,6 +1494,40 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("project monograms validate text and palette colors", () => + Effect.gen(function* () { + for (const text of ["A", "T3", "É", "文書"]) { + const projectIcon = { kind: "monogram", text, color: "violet" } 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: "monogram", text: "", color: "blue" }, + { kind: "monogram", text: "ABC", color: "blue" }, + { kind: "monogram", text: "A B", color: "blue" }, + { kind: "monogram", text: "🚀", color: "blue" }, + { kind: "monogram", text: "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..b77969805801 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -459,7 +459,16 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); +export const ProjectMonogramText = TrimmedNonEmptyString.check( + Schema.isPattern(/^[\p{L}\p{N}]{1,2}$/u), +); + export const ProjectIconOverride = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("monogram"), + text: ProjectMonogramText, + color: ProjectIconColor, + }), Schema.Struct({ kind: Schema.Literal("lucide"), name: ProjectLucideIconName, diff --git a/packages/shared/package.json b/packages/shared/package.json index b213261b0c3a..43a3e94216ae 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,6 +23,10 @@ "types": "./src/themePreview.ts", "import": "./src/themePreview.ts" }, + "./projectIconColors": { + "types": "./src/projectIconColors.ts", + "import": "./src/projectIconColors.ts" + }, "./projectFavicon": { "types": "./src/projectFavicon.ts", "import": "./src/projectFavicon.ts" diff --git a/apps/web/src/projectIconColors.ts b/packages/shared/src/projectIconColors.ts similarity index 100% rename from apps/web/src/projectIconColors.ts rename to packages/shared/src/projectIconColors.ts From f82a1704884741691b0339c66b2ef5e6680f1f06 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Mon, 14 Sep 2026 19:50:18 -0700 Subject: [PATCH 3/4] refactor: keep project monogram rendering scoped to web and desktop --- apps/mobile/global.css | 1 - apps/mobile/src/components/ProjectFavicon.tsx | 38 +------------------ .../archive/ArchivedThreadsScreen.tsx | 1 - .../features/threads/NewTaskRouteScreen.tsx | 1 - .../features/threads/thread-list-items.tsx | 1 - .../features/threads/thread-list-v2-items.tsx | 3 -- apps/web/src/components/ProjectFavicon.tsx | 2 +- apps/web/src/components/ProjectMonogram.tsx | 2 +- apps/web/src/index.css | 1 - .../web}/src/projectIconColors.ts | 0 apps/web/src/projectIconOptions.ts | 2 +- apps/web/src/projectIdentity.test.ts | 2 +- apps/web/src/projectIdentity.ts | 2 +- packages/shared/package.json | 4 -- 14 files changed, 7 insertions(+), 53 deletions(-) rename {packages/shared => apps/web}/src/projectIconColors.ts (100%) diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 21e87ba9f9e9..7a401fd7289d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -1,6 +1,5 @@ @import "tailwindcss"; @import "uniwind"; -@source "../../packages/shared/src/projectIconColors.ts"; @import "./generated-uniwind-themes.css"; /* ─── Theme tokens ──────────────────────────────────────────────────── */ diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 585c5ba74d36..932fc6779f20 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,11 +1,8 @@ -import Svg, { Rect, Text as SvgText } from "react-native-svg"; -import { useResolveClassNames } from "uniwind"; -import { projectIconColorClassName } from "@t3tools/shared/projectIconColors"; import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; -import { StyleSheet, View } from "react-native"; -import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; +import { View } from "react-native"; +import type { EnvironmentId } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, @@ -33,7 +30,6 @@ 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 faviconUrl = useAtomValue( @@ -55,10 +51,6 @@ export function ProjectFavicon(props: { : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; - if (props.projectIcon?.kind === "monogram") { - return ; - } - return ( ); } - -function ProjectMonogram({ - icon, - size, -}: { - readonly icon: Extract; - readonly size: number; -}) { - const { color } = StyleSheet.flatten(useResolveClassNames(projectIconColorClassName(icon.color))); - return ( - - - - {icon.text} - - - ); -} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 6f6ed83fe505..97f9c5b69d0f 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -375,7 +375,6 @@ function ProjectGroupLabel(props: { { diff --git a/apps/web/src/projectIdentity.ts b/apps/web/src/projectIdentity.ts index 95b02225e0ba..840856aa403d 100644 --- a/apps/web/src/projectIdentity.ts +++ b/apps/web/src/projectIdentity.ts @@ -1,4 +1,4 @@ -import { PROJECT_ICON_COLORS } from "@t3tools/shared/projectIconColors"; +import { PROJECT_ICON_COLORS } from "./projectIconColors"; import type { ProjectIconColor } from "@t3tools/contracts"; /** Visual identity tokens for a generated project badge. */ diff --git a/packages/shared/package.json b/packages/shared/package.json index 43a3e94216ae..b213261b0c3a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,10 +23,6 @@ "types": "./src/themePreview.ts", "import": "./src/themePreview.ts" }, - "./projectIconColors": { - "types": "./src/projectIconColors.ts", - "import": "./src/projectIconColors.ts" - }, "./projectFavicon": { "types": "./src/projectFavicon.ts", "import": "./src/projectFavicon.ts" From 35a4aff472dfda0392e24e356515922d7fb89b91 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Mon, 14 Sep 2026 20:32:36 -0700 Subject: [PATCH 4/4] fix: preserve older-client compatibility for project monograms --- .../Layers/ProjectionPipeline.test.ts | 6 ++- apps/web/src/components/ProjectFavicon.tsx | 4 +- apps/web/src/components/ProjectMonogram.tsx | 4 +- .../settings/ProjectIconPickerDialog.tsx | 16 ++++-- .../settings/ProjectSettingsPanel.tsx | 12 ++--- packages/contracts/src/orchestration.test.ts | 52 ++++++++++++++++--- packages/contracts/src/orchestration.ts | 12 ++--- 7 files changed, 76 insertions(+), 30 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 04d00318d7fb..90f7470a8c5c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4371,12 +4371,14 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { type: "project.meta.update", commandId: CommandId.make("cmd-monogram-save"), projectId, - projectIcon: { kind: "monogram", text: "T3", color: "violet" }, + 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":"monogram","text":"T3","color":"violet"}' }]); + 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"), diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index cf88d4965f09..edf9f23e23a7 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -43,10 +43,10 @@ export function ProjectFavicon(input: { faviconPath: project.faviconPath, }), ); - if (project.projectIcon?.kind === "monogram") { + if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) { return ( diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx index d63abf02c777..04a44e166ea2 100644 --- a/apps/web/src/components/ProjectMonogram.tsx +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -2,6 +2,8 @@ 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, @@ -38,7 +40,7 @@ export function ProjectMonogram({ className="font-mono" fontSize="8.25" fontWeight="700" - textLength={Array.from(text).length === 1 ? 6 : 12} + textLength={Array.from(monogramSegmenter.segment(text)).length === 1 ? 6 : 12} lengthAdjust="spacingAndGlyphs" textRendering="geometricPrecision" > diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index f2474e531c07..8bcdcc617ef7 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -54,7 +54,9 @@ export function ProjectIconPickerDialog({ readonly onSelect: (icon: ProjectIconOverride) => void; }) { const automatic = deriveProjectIdentity(projectName); - const [mode, setMode] = useState(current?.kind ?? "lucide"); + 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, ); @@ -62,7 +64,7 @@ export function ProjectIconPickerDialog({ current && current.kind !== "emoji" ? current.color : automatic.color, ); const [letters, setLetters] = useState( - current?.kind === "monogram" ? current.text : automatic.monogram, + current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, ); const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); const [query, setQuery] = useState(""); @@ -71,10 +73,14 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind ?? "lucide"); + setMode( + current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), + ); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); setColor(current && current.kind !== "emoji" ? current.color : automatic.color); - setLetters(current?.kind === "monogram" ? current.text : automatic.monogram); + setLetters( + current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, + ); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); setQuery(""); setCustomEmoji(""); @@ -90,7 +96,7 @@ export function ProjectIconPickerDialog({ if (mode === "monogram" && !validMonogram) return; onSelect( mode === "monogram" - ? { kind: "monogram", text: monogram, color } + ? { kind: "lucide", name: DEFAULT_ICON, monogram, color } : mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji }, diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d16fa20b5a05..3e4cfd22a937 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -434,13 +434,11 @@ function ProjectDetail({ }), ); +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", "É", "文書"]) { - const projectIcon = { kind: "monogram", text, color: "violet" } as const; + 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", @@ -1509,11 +1545,13 @@ it.effect("project monograms validate text and palette colors", () => assert.deepEqual(command.projectIcon, projectIcon); } for (const projectIcon of [ - { kind: "monogram", text: "", color: "blue" }, - { kind: "monogram", text: "ABC", color: "blue" }, - { kind: "monogram", text: "A B", color: "blue" }, - { kind: "monogram", text: "🚀", color: "blue" }, - { kind: "monogram", text: "T3", color: "ultraviolet" }, + { 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({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b77969805801..6f2dbf733293 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -459,20 +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.isPattern(/^[\p{L}\p{N}]{1,2}$/u), + 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("monogram"), - text: ProjectMonogramText, - color: ProjectIconColor, - }), 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"),