Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4353,6 +4353,45 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
}),
);

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: "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":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' },
]);
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;
Expand Down
53 changes: 11 additions & 42 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react";
import { projectFaviconUrlAtom } from "../state/assets";
import { deriveProjectIdentity } from "../projectIdentity";
import { projectIconColorClassName } from "../projectIconColors";
import { ProjectMonogram } from "./ProjectMonogram";
import { cn } from "~/lib/utils";

const DynamicIcon = lazy(() =>
Expand Down Expand Up @@ -42,6 +43,15 @@ export function ProjectFavicon(input: {
faviconPath: project.faviconPath,
}),
);
if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) {
return (
<ProjectMonogram
text={project.projectIcon.monogram}
color={project.projectIcon.color}
className={input.className}
/>
);
}
if (project.projectIcon?.kind === "emoji") {
return (
<ProjectFaviconFallback
Expand Down Expand Up @@ -112,49 +122,8 @@ function ProjectFaviconFallback({
}) {
if (projectName && projectName.trim().length > 0) {
const identity = deriveProjectIdentity(projectName);
// Wrapped like the emoji and Lucide branches so the monogram sits where an
// <img> 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 (
<span
aria-hidden="true"
className={cn("inline-flex size-4 shrink-0 items-center justify-center", className)}
>
<svg
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%)`,
}}
>
<text
x="8"
y="10.8"
textAnchor="middle"
fill="white"
className="font-mono"
fontSize="8.25"
fontWeight="700"
textLength="12"
lengthAdjust="spacingAndGlyphs"
textRendering="geometricPrecision"
>
{identity.monogram}
</text>
<rect
x="0.25"
y="0.25"
width="15.5"
height="15.5"
rx="3.75"
fill="none"
strokeWidth="0.5"
className="stroke-black/10 dark:stroke-white/10"
/>
</svg>
</span>
<ProjectMonogram text={identity.monogram} color={identity.color} className={className} />
);
}

Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/components/ProjectMonogram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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,
className,
}: {
readonly text: string;
readonly color: ProjectIconColor;
readonly className?: string | undefined;
}) {
// Wrapped like the emoji and Lucide branches so the monogram sits where an
// <img> 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 (
<span
aria-hidden="true"
className={cn("inline-flex size-4 shrink-0 items-center justify-center", className)}
>
<svg
viewBox="0 0 16 16"
className={cn(
"size-full overflow-hidden rounded-[25%] font-mono select-none",
projectIconColorClassName(color),
)}
style={{
backgroundColor: "color-mix(in srgb, currentColor 14%, transparent)",
}}
>
<text
x="8"
y="10.8"
textAnchor="middle"
fill="currentColor"
className="font-mono"
fontSize="8.25"
fontWeight="700"
textLength={Array.from(monogramSegmenter.segment(text)).length === 1 ? 6 : 12}
lengthAdjust="spacingAndGlyphs"
textRendering="geometricPrecision"
>
{text}
</text>
</svg>
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog";
describe("ProjectIconPickerDialog", () => {
it("shows icons first and selects them for an automatic project", () => {
const markup = renderToStaticMarkup(
<ProjectIconPickerDialog current={null} open onOpenChange={() => {}} onSelect={() => {}} />,
<ProjectIconPickerDialog
current={null}
projectName="Test"
open
onOpenChange={() => {}}
onSelect={() => {}}
/>,
);

expect(markup).toContain('data-current="lucide"');
Expand Down
119 changes: 87 additions & 32 deletions apps/web/src/components/settings/ProjectIconPickerDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand All @@ -35,23 +42,29 @@ 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<ProjectIconOverride["kind"] | "monogram">(
current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
);
const [iconName, setIconName] = useState<IconName>(
current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
);
const [color, setColor] = useState<ProjectIconColor>(
current?.kind === "lucide" ? current.color : DEFAULT_COLOR,
current && current.kind !== "emoji" ? current.color : automatic.color,
);
const [letters, setLetters] = useState(
current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,
);
const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");
const [query, setQuery] = useState("");
Expand All @@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({

useEffect(() => {
if (open && !previousOpenRef.current) {
setMode(current?.kind === "emoji" ? "emoji" : "lucide");
setMode(
current?.kind === "lucide" && current.monogram ? "monogram" : (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 === "lucide" && current.monogram ? current.monogram : 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: "lucide", name: DEFAULT_ICON, monogram, color }
: mode === "lucide"
? { kind: "lucide", name: iconName, color }
: { kind: "emoji", emoji },
);
onOpenChange(false);
};
Expand All @@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({
<DialogPopup className="w-full sm:w-[32rem]">
<DialogHeader>
<DialogTitle>Choose project icon</DialogTitle>
<DialogDescription>Pick any Lucide icon and color, or use an emoji.</DialogDescription>
<DialogDescription>Choose an icon, emoji, or monogram.</DialogDescription>
</DialogHeader>
<DialogPanel className="flex min-h-0 flex-col gap-4">
<ToggleGroup
Expand All @@ -93,35 +118,39 @@ export function ProjectIconPickerDialog({
value={[mode]}
onValueChange={(next) => {
const value = next[0];
if (value === "lucide" || value === "emoji") setMode(value);
if (value === "lucide" || value === "emoji" || value === "monogram") setMode(value);
}}
>
<Toggle value="lucide">Icons</Toggle>
<Toggle value="emoji">Emoji</Toggle>
<Toggle value="monogram">Monogram</Toggle>
</ToggleGroup>

{mode !== "emoji" ? (
<div>
<div className="mb-2 text-xs font-medium text-muted-foreground">Color</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Icon color">
{PROJECT_ICON_COLORS.map((option) => (
<button
key={option.value}
type="button"
aria-label={option.label}
aria-pressed={color === option.value}
className={cn(
"flex size-6 items-center justify-center rounded-full border border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring",
color === option.value && "border-foreground/64",
)}
onClick={() => setColor(option.value)}
>
<span className={cn("size-4 rounded-full", option.swatchClassName)} />
</button>
))}
</div>
</div>
) : null}

{mode === "lucide" ? (
<>
<div>
<div className="mb-2 text-xs font-medium text-muted-foreground">Color</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Icon color">
{PROJECT_ICON_COLORS.map((option) => (
<button
key={option.value}
type="button"
aria-label={option.label}
aria-pressed={color === option.value}
className={cn(
"flex size-6 items-center justify-center rounded-full border border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring",
color === option.value && "border-foreground/64",
)}
onClick={() => setColor(option.value)}
>
<span className={cn("size-4 rounded-full", option.swatchClassName)} />
</button>
))}
</div>
</div>
<Input
type="search"
value={query}
Expand Down Expand Up @@ -153,6 +182,30 @@ export function ProjectIconPickerDialog({
<p className="py-8 text-center text-sm text-muted-foreground">No icons found.</p>
) : null}
</>
) : mode === "monogram" ? (
<div className="flex items-center gap-4 py-2">
<ProjectMonogram
text={validMonogram ? monogram : automatic.monogram}
color={color}
className="size-12"
/>
<div className="flex-1 space-y-2">
<label htmlFor="project-monogram" className="text-sm font-medium">
Letters
</label>
<Input
id="project-monogram"
value={letters}
onChange={(event) => setLetters(event.currentTarget.value)}
aria-describedby="project-monogram-hint"
aria-invalid={!validMonogram}
autoComplete="off"
/>
<p id="project-monogram-hint" className="text-xs text-muted-foreground">
One or two letters or numbers.
</p>
</div>
</div>
) : (
<>
<ScrollArea scrollFade className="max-h-64">
Expand Down Expand Up @@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={save}>Save icon</Button>
<Button onClick={save} disabled={mode === "monogram" && !validMonogram}>
Save icon
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
Expand Down
Loading
Loading