| author | |
| committer | |
| log | 438465d6b7dd2e50dd38862a0db1252d2d72ecfc |
| tree | 114ae703143c8fc6e0b5bd1366357a28e7427a96 |
| parent | d07ffbe351a10e1001f6d8eafa5b7533b8bd7e23 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 4355 | 4355 | ||
| 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"; |
| 11 | import { projectFaviconUrlAtom } from "../state/assets"; | 11 | import { projectFaviconUrlAtom } from "../state/assets"; |
| 12 | import { deriveProjectIdentity } from "../projectIdentity"; | 12 | import { deriveProjectIdentity } from "../projectIdentity"; |
| 13 | import { projectIconColorClassName } from "../projectIconColors"; | 13 | import { projectIconColorClassName } from "../projectIconColors"; |
| 14 | import { ProjectMonogram } from "./ProjectMonogram"; | ||
| 14 | import { cn } from "~/lib/utils"; | 15 | import { cn } from "~/lib/utils"; |
| 15 | 16 | ||
| 16 | const DynamicIcon = lazy(() => | 17 | const 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 | <ProjectFaviconFallback | 57 | <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 | <span | 126 | <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 | } |
| 160 | 129 |
apps/web/src/components/ProjectMonogram.tsx created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import type { ProjectIconColor } from "@t3tools/contracts"; | ||
| 2 | import { projectIconColorClassName } from "../projectIconColors"; | ||
| 3 | import { cn } from "~/lib/utils"; | ||
| 4 | |||
| 5 | const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); | ||
| 6 | |||
| 7 | export 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"; |
| 41 | describe("ProjectIconPickerDialog", () => { | 41 | describe("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 | ); |
| 46 | 52 | ||
| 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 @@ |
| 1 | import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { deriveProjectIdentity } from "../../projectIdentity"; | ||
| 3 | import { ProjectMonogram } from "../ProjectMonogram"; | ||
| 4 | import { | ||
| 5 | ProjectMonogramText, | ||
| 6 | type ProjectIconColor, | ||
| 7 | type ProjectIconOverride, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 2 | import { DynamicIcon, type IconName } from "lucide-react/dynamic"; | 9 | import { DynamicIcon, type IconName } from "lucide-react/dynamic"; |
| 3 | import { useEffect, useMemo, useRef, useState } from "react"; | 10 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { | 11 | import { |
| ... | @@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area"; | ... | @@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area"; |
| 24 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | 31 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 25 | 32 | ||
| 26 | const DEFAULT_ICON: IconName = "folder-code"; | 33 | const DEFAULT_ICON: IconName = "folder-code"; |
| 27 | const DEFAULT_COLOR: ProjectIconColor = "blue"; | 34 | const isMonogramText = Schema.is(ProjectMonogramText); |
| 28 | 35 | ||
| 29 | function iconLabel(name: string): string { | 36 | function iconLabel(name: string): string { |
| 30 | return name | 37 | return name |
| ... | @@ -35,23 +42,29 @@ function iconLabel(name: string): string { | ... | @@ -35,23 +42,29 @@ function iconLabel(name: string): string { |
| 35 | 42 | ||
| 36 | export function ProjectIconPickerDialog({ | 43 | export 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({ |
| 60 | 73 | ||
| 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]); |
| 72 | 90 | ||
| 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 | <ToggleGroup | 115 | <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> |
| 102 | 128 | ||
| 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 | <Input | 154 | <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 | Cancel | 251 | 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.emoji | 440 | ? 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 | <ProjectIconPickerDialog | 531 | <ProjectIconPickerDialog |
| 532 | current={projectIcon} | 532 | current={projectIcon} |
| 533 | projectName={representative.title} | ||
| 533 | open | 534 | 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { PROJECT_ICON_COLORS } from "./projectIconColors"; | ||
| 2 | import { deriveProjectIdentity } from "./projectIdentity"; | 3 | import { deriveProjectIdentity } from "./projectIdentity"; |
| 3 | 4 | ||
| 4 | describe("deriveProjectIdentity", () => { | 5 | describe("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 "); |
| 20 | 21 | ||
| 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 | }); |
| 24 | 31 | ||
| 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 | ); |
| 31 | 38 |
apps/web/src/projectIdentity.ts+9-9| ... | @@ -1,8 +1,10 @@ | ... | @@ -1,8 +1,10 @@ |
| 1 | import { PROJECT_ICON_COLORS } from "./projectIconColors"; | ||
| 2 | import type { ProjectIconColor } from "@t3tools/contracts"; | ||
| 3 | |||
| 1 | /** Visual identity tokens for a generated project badge. */ | 4 | /** Visual identity tokens for a generated project badge. */ |
| 2 | export interface ProjectIdentity { | 5 | export interface ProjectIdentity { |
| 3 | readonly monogram: string; | 6 | readonly monogram: string; |
| 4 | readonly background: string; | 7 | readonly color: ProjectIconColor; |
| 5 | readonly highlight: string; | ||
| 6 | } | 8 | } |
| 7 | 9 | ||
| 8 | function normalizeProjectName(projectName: string): string { | 10 | function 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 | } |
| 25 | 27 | ||
| 26 | function projectHue(projectName: string): number { | 28 | function 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 | } |
| 34 | 36 | ||
| 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. */ |
| 36 | export function deriveProjectIdentity(projectName: string): ProjectIdentity { | 38 | export 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. |
| 43 | 43 | ||
| 44 | ## Project icons | 44 | ## Project icons |
| 45 | 45 | ||
| 46 | Select the project and open Project to choose an icon, emoji, or image. The choice applies to | 46 | Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to |
| 47 | every checkout in the project group and appears on connected clients. Choose **Automatic** to let | 47 | every checkout in the project group and appears on connected clients. Choose **Automatic** to let |
| 48 | T3 Code detect an icon again. | 48 | T3 Code detect an icon again. |
| 49 | 49 | ||
| 50 | When no image is found, web and desktop show a two-character monogram with colors | 50 | Choose **Monogram** in the icon picker to set one or two letters or numbers and a color. |
| 51 | derived from the saved project name. For example, `Nebula` becomes `NA`, | 51 | |
| 52 | When no image is found, web and desktop show a two-character monogram with a color | ||
| 53 | from 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`. |
| 53 | 55 | ||
| 54 | ## Keep the default branch current | 56 | ## 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"; |
| 40 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | 42 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 41 | const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); | 43 | const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); |
| 42 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | 44 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); |
| 45 | // The icon shape understood by clients released before monograms. | ||
| 46 | const 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 | ]); | ||
| 50 | const decodeLegacyProjectShell = Schema.decodeUnknownEffect( | ||
| 51 | Schema.Struct({ | ||
| 52 | ...OrchestrationProjectShell.fields, | ||
| 53 | projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)), | ||
| 54 | }), | ||
| 55 | ); | ||
| 56 | const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); | ||
| 43 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); | 57 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); |
| 44 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); | 58 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); |
| 45 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); | 59 | const 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 | ); |
| 1496 | 1510 | ||
| 1511 | it.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 | |||
| 1528 | it.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 | |||
| 1497 | it.effect("rejects thread history imports without messages", () => | 1569 | it.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( |
| 459 | 459 | ||
| 460 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); | 460 | const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); |
| 461 | 461 | ||
| 462 | const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); | ||
| 463 | export 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 | |||
| 462 | export const ProjectIconOverride = Schema.Union([ | 469 | export 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"), |