| 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 | 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 | 4395 | it.effect("re-creating a deleted thread id starts from an empty projection", () => |
| 4357 | 4396 | Effect.gen(function* () { |
| 4358 | 4397 | const engine = yield* OrchestrationEngineService; |
apps/web/src/components/ProjectFavicon.tsx+11-42| ... | ... | @@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 11 | 11 | import { projectFaviconUrlAtom } from "../state/assets"; |
| 12 | 12 | import { deriveProjectIdentity } from "../projectIdentity"; |
| 13 | 13 | import { projectIconColorClassName } from "../projectIconColors"; |
| 14 | import { ProjectMonogram } from "./ProjectMonogram"; | |
| 14 | 15 | import { cn } from "~/lib/utils"; |
| 15 | 16 | |
| 16 | 17 | const DynamicIcon = lazy(() => |
| ... | ... | @@ -42,6 +43,15 @@ export function ProjectFavicon(input: { |
| 42 | 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 | 55 | if (project.projectIcon?.kind === "emoji") { |
| 46 | 56 | return ( |
| 47 | 57 | <ProjectFaviconFallback |
| ... | ... | @@ -112,49 +122,8 @@ function ProjectFaviconFallback({ |
| 112 | 122 | }) { |
| 113 | 123 | if (projectName && projectName.trim().length > 0) { |
| 114 | 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 | 125 | 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} /> | |
| 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 | 41 | describe("ProjectIconPickerDialog", () => { |
| 42 | 42 | it("shows icons first and selects them for an automatic project", () => { |
| 43 | 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 | 53 | expect(markup).toContain('data-current="lucide"'); |
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+87-32| ... | ... | @@ -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 | 9 | import { DynamicIcon, type IconName } from "lucide-react/dynamic"; |
| 3 | 10 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 4 | 11 | import { |
| ... | ... | @@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area"; |
| 24 | 31 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 25 | 32 | |
| 26 | 33 | const DEFAULT_ICON: IconName = "folder-code"; |
| 27 | const DEFAULT_COLOR: ProjectIconColor = "blue"; | |
| 34 | const isMonogramText = Schema.is(ProjectMonogramText); | |
| 28 | 35 | |
| 29 | 36 | function iconLabel(name: string): string { |
| 30 | 37 | return name |
| ... | ... | @@ -35,23 +42,29 @@ function iconLabel(name: string): string { |
| 35 | 42 | |
| 36 | 43 | export function ProjectIconPickerDialog({ |
| 37 | 44 | current, |
| 45 | projectName, | |
| 38 | 46 | open, |
| 39 | 47 | onOpenChange, |
| 40 | 48 | onSelect, |
| 41 | 49 | }: { |
| 42 | 50 | readonly current: ProjectIconOverride | null; |
| 51 | readonly projectName: string; | |
| 43 | 52 | readonly open: boolean; |
| 44 | 53 | readonly onOpenChange: (open: boolean) => void; |
| 45 | 54 | readonly onSelect: (icon: ProjectIconOverride) => void; |
| 46 | 55 | }) { |
| 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"), | |
| 49 | 59 | ); |
| 50 | 60 | const [iconName, setIconName] = useState<IconName>( |
| 51 | 61 | current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, |
| 52 | 62 | ); |
| 53 | 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 | 69 | const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); |
| 57 | 70 | const [query, setQuery] = useState(""); |
| ... | ... | @@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({ |
| 60 | 73 | |
| 61 | 74 | useEffect(() => { |
| 62 | 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 | 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 | 84 | setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); |
| 67 | 85 | setQuery(""); |
| 68 | 86 | setCustomEmoji(""); |
| 69 | 87 | } |
| 70 | 88 | previousOpenRef.current = open; |
| 71 | }, [current, open]); | |
| 89 | }, [current, open, automatic.color, automatic.monogram]); | |
| 72 | 90 | |
| 73 | 91 | const icons = useMemo(() => filterProjectIconNames(query), [query]); |
| 74 | 92 | const selectedColorClassName = projectIconColorClassName(color); |
| 93 | const monogram = letters.normalize("NFKC").trim().toUpperCase(); | |
| 94 | const validMonogram = isMonogramText(monogram); | |
| 75 | 95 | const save = () => { |
| 96 | if (mode === "monogram" && !validMonogram) return; | |
| 76 | 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 | 104 | onOpenChange(false); |
| 80 | 105 | }; |
| ... | ... | @@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({ |
| 84 | 109 | <DialogPopup className="w-full sm:w-[32rem]"> |
| 85 | 110 | <DialogHeader> |
| 86 | 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 | 113 | </DialogHeader> |
| 89 | 114 | <DialogPanel className="flex min-h-0 flex-col gap-4"> |
| 90 | 115 | <ToggleGroup |
| ... | ... | @@ -93,35 +118,39 @@ export function ProjectIconPickerDialog({ |
| 93 | 118 | value={[mode]} |
| 94 | 119 | onValueChange={(next) => { |
| 95 | 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 | 124 | <Toggle value="lucide">Icons</Toggle> |
| 100 | 125 | <Toggle value="emoji">Emoji</Toggle> |
| 126 | <Toggle value="monogram">Monogram</Toggle> | |
| 101 | 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 | 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 | 154 | <Input |
| 126 | 155 | type="search" |
| 127 | 156 | value={query} |
| ... | ... | @@ -153,6 +182,30 @@ export function ProjectIconPickerDialog({ |
| 153 | 182 | <p className="py-8 text-center text-sm text-muted-foreground">No icons found.</p> |
| 154 | 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 | 211 | <ScrollArea scrollFade className="max-h-64"> |
| ... | ... | @@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({ |
| 197 | 250 | <Button variant="outline" onClick={() => onOpenChange(false)}> |
| 198 | 251 | Cancel |
| 199 | 252 | </Button> |
| 200 | <Button onClick={save}>Save icon</Button> | |
| 253 | <Button onClick={save} disabled={mode === "monogram" && !validMonogram}> | |
| 254 | Save icon | |
| 255 | </Button> | |
| 201 | 256 | </DialogFooter> |
| 202 | 257 | </DialogPopup> |
| 203 | 258 | </Dialog> |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+2-1| ... | ... | @@ -435,7 +435,7 @@ function ProjectDetail({ |
| 435 | 435 | title="Project icon" |
| 436 | 436 | description={ |
| 437 | 437 | projectIcon?.kind === "lucide" |
| 438 | ? `${projectIcon.name} · ${projectIcon.color}` | |
| 438 | ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}` | |
| 439 | 439 | : projectIcon?.kind === "emoji" |
| 440 | 440 | ? projectIcon.emoji |
| 441 | 441 | : (faviconPath ?? "Automatic") |
| ... | ... | @@ -530,6 +530,7 @@ function ProjectDetail({ |
| 530 | 530 | <Suspense fallback={null}> |
| 531 | 531 | <ProjectIconPickerDialog |
| 532 | 532 | current={projectIcon} |
| 533 | projectName={representative.title} | |
| 533 | 534 | open |
| 534 | 535 | onOpenChange={setIconPickerOpen} |
| 535 | 536 | onSelect={(icon) => void setProjectIcon({ faviconPath: null, projectIcon: icon })} |
apps/web/src/projectIdentity.test.ts+10-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { PROJECT_ICON_COLORS } from "./projectIconColors"; | |
| 2 | 3 | import { deriveProjectIdentity } from "./projectIdentity"; |
| 3 | 4 | |
| 4 | 5 | describe("deriveProjectIdentity", () => { |
| ... | ... | @@ -18,14 +19,20 @@ describe("deriveProjectIdentity", () => { |
| 18 | 19 | const canonical = deriveProjectIdentity("Nebula"); |
| 19 | 20 | const equivalent = deriveProjectIdentity(" NEBULA "); |
| 20 | 21 | |
| 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 | } | |
| 23 | 30 | }); |
| 24 | 31 | |
| 25 | 32 | it("generates different hues for different project names", () => { |
| 26 | 33 | const colors = new Set( |
| 27 | 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 | import { PROJECT_ICON_COLORS } from "./projectIconColors"; | |
| 2 | import type { ProjectIconColor } from "@t3tools/contracts"; | |
| 3 | ||
| 1 | 4 | /** Visual identity tokens for a generated project badge. */ |
| 2 | 5 | export interface ProjectIdentity { |
| 3 | 6 | readonly monogram: string; |
| 4 | readonly background: string; | |
| 5 | readonly highlight: string; | |
| 7 | readonly color: ProjectIconColor; | |
| 6 | 8 | } |
| 7 | 9 | |
| 8 | 10 | function normalizeProjectName(projectName: string): string { |
| ... | ... | @@ -23,21 +25,19 @@ function projectMonogram(projectName: string): string { |
| 23 | 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 | 29 | const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project"; |
| 28 | let hue = 0; | |
| 30 | let index = 0; | |
| 29 | 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 | 37 | /** Derives the stable monogram and generated colors used when a project has no icon. */ |
| 36 | 38 | export function deriveProjectIdentity(projectName: string): ProjectIdentity { |
| 37 | const hue = projectHue(projectName); | |
| 38 | 39 | return { |
| 39 | 40 | monogram: projectMonogram(projectName), |
| 40 | background: `hsl(${hue} 48% 36%)`, | |
| 41 | highlight: `hsl(${(hue + 24) % 360} 58% 48%)`, | |
| 41 | color: projectColor(projectName), | |
| 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 | 43 | |
| 44 | 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 | 47 | every checkout in the project group and appears on connected clients. Choose **Automatic** to let |
| 48 | 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 | |
| 51 | derived from the saved project name. For example, `Nebula` becomes `NA`, | |
| 50 | Choose **Monogram** in the icon picker to set one or two letters or numbers and a color. | |
| 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 | 54 | `Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`. |
| 53 | 55 | |
| 54 | 56 | ## Keep the default branch current |
packages/contracts/src/orchestration.test.ts+72| ... | ... | @@ -17,6 +17,8 @@ import { |
| 17 | 17 | OrchestrationGetTurnDiffInput, |
| 18 | 18 | OrchestrationLatestTurn, |
| 19 | 19 | ProjectCreatedPayload, |
| 20 | OrchestrationProjectShell, | |
| 21 | ProjectIconColor, | |
| 20 | 22 | ProjectMetaUpdatedPayload, |
| 21 | 23 | OrchestrationProposedPlan, |
| 22 | 24 | OrchestrationSession, |
| ... | ... | @@ -40,6 +42,18 @@ import { ProviderInstanceId } from "./providerInstance.ts"; |
| 40 | 42 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 41 | 43 | const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); |
| 42 | 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 | 57 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); |
| 44 | 58 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); |
| 45 | 59 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); |
| ... | ... | @@ -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 | 1569 | it.effect("rejects thread history imports without messages", () => |
| 1498 | 1570 | Effect.gen(function* () { |
| 1499 | 1571 | const result = yield* Effect.exit( |
packages/contracts/src/orchestration.ts+9| ... | ... | @@ -459,11 +459,20 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( |
| 459 | 459 | |
| 460 | 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 | 469 | export const ProjectIconOverride = Schema.Union([ |
| 463 | 470 | Schema.Struct({ |
| 464 | 471 | kind: Schema.Literal("lucide"), |
| 465 | 472 | name: ProjectLucideIconName, |
| 466 | 473 | color: ProjectIconColor, |
| 474 | // Older clients ignore this field and render the named Lucide icon instead. | |
| 475 | monogram: Schema.optional(ProjectMonogramText), | |
| 467 | 476 | }), |
| 468 | 477 | Schema.Struct({ |
| 469 | 478 | kind: Schema.Literal("emoji"), |