diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index b60cf3ae62c4..5f52e4fdac52 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -1,60 +1,19 @@ -import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", lastTarget: null as unknown, + projectMonogramColor: "auto", })); -const hooks = vi.hoisted(() => { - let cursor = 0; - let slots: unknown[] = []; - const nextIndex = () => cursor++; - - return { - beginRender() { - cursor = 0; - }, - reset() { - cursor = 0; - slots = []; - }, - useMemoCache(size: number): unknown[] { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); - } - return slots[index] as unknown[]; - }, - useState(initialValue: T | (() => T)): [T, Dispatch>] { - const index = nextIndex(); - if (index >= slots.length) { - slots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setValue: Dispatch> = (nextValue) => { - const previous = slots[index] as T; - slots[index] = - typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; - }; - return [slots[index] as T, setValue]; - }, - }; -}); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useState: hooks.useState, - }; -}); - -vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("lucide-react/dynamic", () => ({ - DynamicIcon: "dynamic-icon", + DynamicIcon: (props: { name: string; className: string }) => ( + + ), iconNames: ["alarm-clock", "folder-code"], })); vi.mock("@effect/atom-react", () => ({ @@ -65,9 +24,17 @@ vi.mock("../state/assets", () => ({ testState.lastTarget = input; }, })); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: (selector?: (settings: { projectMonogramColor: string }) => unknown) => { + const settings = { projectMonogramColor: testState.projectMonogramColor }; + return selector ? selector(settings) : settings; + }, +})); import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; +let renderer: ReactTestRenderer | undefined; + function makeProject( overrides: Partial & Pick, @@ -75,140 +42,144 @@ function makeProject( return { environmentId: "environment-test" as EnvironmentId, ...overrides }; } -type ProjectFaviconImageProps = { - readonly cacheKey: string; - readonly src: string; - readonly className?: string | undefined; - readonly fallbackIcon: ComponentType<{ className?: string }>; -}; - -type ImageElement = ReactElement<{ - readonly src: string; - readonly onLoad?: () => void; - readonly onError?: () => void; -}>; - -type ProjectFaviconImageElement = ReactElement<{ - readonly children: [ReactElement | null, ImageElement | null, ImageElement | null]; -}>; - -function resolveImageComponent(): { - readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement; - readonly props: ProjectFaviconImageProps; -} { - hooks.beginRender(); - const element = ProjectFavicon({ - project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }), - }) as ReactElement; - hooks.reset(); +async function renderProject(project: ProjectFaviconProject) { + if (renderer) { + await act(() => renderer?.unmount()); + } + await act(() => { + renderer = create(); + }); + const rendered = renderer; + if (!rendered) throw new Error("Project favicon renderer was not created"); + return rendered; +} +async function renderMissingImageMonogram(title: string) { + testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; + const rendered = await renderProject( + makeProject({ workspaceRoot: "/workspace/monogram", title }), + ); + const svg = rendered.root.findByType("svg"); + const text = rendered.root.findByType("text"); return { - Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, - props: element.props, + backgroundColor: svg.props.style?.backgroundColor, + backgroundImage: svg.props.style?.backgroundImage, + fill: text.props.fill, + glyph: text.children.join(""), }; } -function renderImage( - Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, - props: ProjectFaviconImageProps, -): ProjectFaviconImageElement { - hooks.beginRender(); - return Component(props); -} - describe("ProjectFavicon", () => { beforeEach(() => { - hooks.reset(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; + testState.projectMonogramColor = "auto"; }); - it("shows the project monogram when no favicon exists", () => { - testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; + afterEach(async () => { + if (renderer) { + await act(() => renderer?.unmount()); + renderer = undefined; + } + vi.unstubAllGlobals(); + }); - const element = ProjectFavicon({ - project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), - }) as ReactElement<{ - readonly projectName?: string; - }>; + it("shows the project monogram when no favicon exists", async () => { + const monogram = await renderMissingImageMonogram("analytics-db"); - expect(element.props.projectName).toBe("analytics-db"); + expect(monogram.glyph).toMatch(/[A-Z]/); }); - it("uses the same monogram fallback for every project category", () => { - testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; - - const element = ProjectFavicon({ - project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), - }) as ReactElement<{ - readonly projectName?: string; - }>; + it("uses the same monogram fallback for every project category", async () => { + const monogram = await renderMissingImageMonogram("agent-runtime"); - expect(element.props.projectName).toBe("agent-runtime"); + expect(monogram.glyph).toMatch(/[A-Z]/); }); - it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { - const element = ProjectFavicon({ - project: makeProject({ + it("renders a saved Lucide icon and color ahead of an uploaded favicon", async () => { + const rendered = await renderProject( + makeProject({ workspaceRoot: "/workspace/test", title: "test", faviconPath: "brand/icon.svg", projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, }), - }) as ReactElement<{ - readonly children: ReactElement<{ - readonly children: ReactElement<{ readonly name: string; readonly className: string }>; - }>; - readonly className: string; - }>; - - expect(element.props.children.props.children.props.name).toBe("alarm-clock"); - expect(element.props.className).toContain("text-violet-600"); - expect(element.props.children.props.children.props.className).toContain("text-violet-600"); + ); + + const icon = rendered.root + .findAllByType("span") + .find((span) => span.props["data-testid"] === "dynamic-icon"); + if (!icon) throw new Error("Dynamic project icon was not rendered"); + expect(icon.props.name).toBe("alarm-clock"); + expect(rendered.root.findByType("span").props.className).toContain("text-violet-600"); + expect(icon.props.className).toContain("text-violet-600"); }); - it("renders a saved emoji ahead of an uploaded favicon", () => { - const element = ProjectFavicon({ - project: makeProject({ + it("renders a saved emoji ahead of an uploaded favicon", async () => { + const rendered = await renderProject( + makeProject({ workspaceRoot: "/workspace/test", title: "test", faviconPath: "brand/icon.svg", projectIcon: { kind: "emoji", emoji: "🦄" }, }), - }) as ReactElement<{ readonly emoji: string }>; + ); - expect(element.props.emoji).toBe("🦄"); + expect(rendered.root.findAllByType("span").some((span) => span.children.includes("🦄"))).toBe( + true, + ); }); - it("falls back when the displayed favicon fails without discarding a valid older image early", () => { - const { Component, props } = resolveImageComponent(); - const initialLoadingImage = renderImage(Component, props).props.children[2]; - initialLoadingImage?.props.onLoad?.(); - - const refreshedProps = { - ...props, - src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg", - }; - const refreshing = renderImage(Component, refreshedProps).props.children; - expect(refreshing[1]?.props.src).toBe(props.src); - refreshing[2]?.props.onError?.(); - - const afterRefreshError = renderImage(Component, refreshedProps).props.children; - expect(afterRefreshError[1]?.props.src).toBe(props.src); - afterRefreshError[1]?.props.onError?.(); - - const afterDisplayedError = renderImage(Component, refreshedProps).props.children; - expect(afterDisplayedError[0]).not.toBeNull(); - expect(afterDisplayedError[1]).toBeNull(); + it("falls back when the displayed favicon fails without discarding a valid older image early", async () => { + const project = makeProject({ + workspaceRoot: "/workspace-test", + title: "workspace-test", + faviconPath: "brand/icon.svg", + }); + const initialSrc = testState.faviconUrl; + const refreshedSrc = "https://environment.test/api/assets/token-b/v1-20-favicon.svg"; + const rendered = await renderProject(project); + + const initialLoadingImage = rendered.root.findAllByType("img")[0]; + if (!initialLoadingImage) throw new Error("Initial favicon image was not rendered"); + await act(() => initialLoadingImage.props.onLoad()); + + testState.faviconUrl = refreshedSrc; + await act(() => { + renderer?.update(); + }); + + expect( + rendered.root.findAllByType("img").filter((image) => image.props.src === initialSrc), + ).toHaveLength(1); + const refreshingImage = rendered.root + .findAllByType("img") + .find((image) => image.props.src === refreshedSrc); + if (!refreshingImage) throw new Error("Refreshed favicon image was not rendered"); + await act(() => refreshingImage.props.onError()); + expect( + rendered.root.findAllByType("img").filter((image) => image.props.src === initialSrc), + ).toHaveLength(1); + + const displayedImage = rendered.root + .findAllByType("img") + .find((image) => image.props.src === initialSrc); + if (!displayedImage) throw new Error("Displayed favicon image was not rendered"); + await act(() => displayedImage.props.onError()); + expect(rendered.root.findByType("svg")).toBeDefined(); + expect( + rendered.root.findAllByType("img").filter((image) => image.props.src === initialSrc), + ).toHaveLength(0); }); - it("requests a saved favicon path when one is set", () => { - ProjectFavicon({ - project: makeProject({ + it("requests a saved favicon path when one is set", async () => { + await renderProject( + makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test", faviconPath: "brand/icon.svg", }), - }); + ); expect(testState.lastTarget).toMatchObject({ environmentId: "environment-test", @@ -216,4 +187,51 @@ describe("ProjectFavicon", () => { faviconPath: "brand/icon.svg", }); }); + + it("uses the project color and current color for missing favicons in automatic mode", async () => { + testState.projectMonogramColor = "auto"; + const monogram = await renderMissingImageMonogram("analytics-db"); + + expect(monogram.backgroundColor).toContain("color-mix"); + expect(monogram.fill).toBe("currentColor"); + }); + + it("uses the theme action color for missing favicons in accent mode", async () => { + testState.projectMonogramColor = "accent"; + const monogram = await renderMissingImageMonogram("analytics-db"); + + expect(monogram.backgroundColor).toBe("var(--primary)"); + expect(monogram.fill).toBe("var(--primary-foreground)"); + }); + + it("keeps the same monogram glyph in both color modes", async () => { + testState.projectMonogramColor = "auto"; + const automatic = await renderMissingImageMonogram("analytics-db"); + testState.projectMonogramColor = "accent"; + const accent = await renderMissingImageMonogram("analytics-db"); + + expect(accent.glyph).toBe(automatic.glyph); + }); + + it("uses the theme action color for failed favicons in accent mode", async () => { + testState.projectMonogramColor = "accent"; + const rendered = await renderProject( + makeProject({ + workspaceRoot: "/workspace-test", + title: "workspace-test", + faviconPath: "brand/icon.svg", + }), + ); + + const loadingImage = rendered.root.findAllByType("img")[0]; + if (!loadingImage) throw new Error("Initial favicon image was not rendered"); + await act(() => loadingImage.props.onLoad()); + const displayedImage = rendered.root.findByType("img"); + await act(() => displayedImage.props.onError()); + + const monogram = rendered.root.findByType("svg"); + const glyph = rendered.root.findByType("text"); + expect(monogram.props.style?.backgroundColor).toBe("var(--primary)"); + expect(glyph.props.fill).toBe("var(--primary-foreground)"); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index edf9f23e23a7..353ad3591848 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -9,6 +9,7 @@ import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; +import { useClientSettings } from "../hooks/useSettings"; import { deriveProjectIdentity } from "../projectIdentity"; import { projectIconColorClassName } from "../projectIconColors"; import { ProjectMonogram } from "./ProjectMonogram"; @@ -120,10 +121,16 @@ function ProjectFaviconFallback({ readonly emoji?: string | undefined; readonly projectName?: string | undefined; }) { + const projectMonogramColor = useClientSettings((settings) => settings.projectMonogramColor); if (projectName && projectName.trim().length > 0) { const identity = deriveProjectIdentity(projectName); return ( - + ); } diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx index 04a44e166ea2..9503f4ac8f3a 100644 --- a/apps/web/src/components/ProjectMonogram.tsx +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -1,4 +1,5 @@ import type { ProjectIconColor } from "@t3tools/contracts"; +import type { ProjectMonogramColor } from "@t3tools/contracts/settings"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; @@ -7,12 +8,16 @@ const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme export function ProjectMonogram({ text, color, + colorMode = "auto", className, }: { readonly text: string; readonly color: ProjectIconColor; + readonly colorMode?: ProjectMonogramColor; readonly className?: string | undefined; }) { + const isAccent = colorMode === "accent"; + // 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 @@ -26,17 +31,19 @@ export function ProjectMonogram({ viewBox="0 0 16 16" className={cn( "size-full overflow-hidden rounded-[25%] font-mono select-none", - projectIconColorClassName(color), + isAccent ? "text-primary-foreground" : projectIconColorClassName(color), )} style={{ - backgroundColor: "color-mix(in srgb, currentColor 14%, transparent)", + backgroundColor: isAccent + ? "var(--primary)" + : "color-mix(in srgb, currentColor 14%, transparent)", }} > void) { ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme ? ["Diff colors"] : []), + ...(settings.projectMonogramColor !== DEFAULT_UNIFIED_SETTINGS.projectMonogramColor + ? ["Project monogram color"] + : []), ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ["Panel animations"] : []), @@ -630,6 +633,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserAutoShowFloatingPreview, settings.appearanceContrast, settings.diffColorScheme, + settings.projectMonogramColor, settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, @@ -738,6 +742,7 @@ export function useSettingsRestore(onRestored?: () => void) { updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, + projectMonogramColor: DEFAULT_UNIFIED_SETTINGS.projectMonogramColor, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, @@ -1364,6 +1369,47 @@ export function AppearanceSettingsPanel() { } /> + + updateSettings({ + projectMonogramColor: DEFAULT_UNIFIED_SETTINGS.projectMonogramColor, + }) + } + /> + ) : null + } + control={ +
+ +
+ } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 19268e2cdaed..1c4ff99c4c98 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -156,6 +156,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"], }, + { + id: "project-monogram-color", + title: "Project monogram color", + to: "/settings/appearance", + searchTerms: ["project monogram badge avatar icon automatic theme accent color colors"], + }, { id: "panel-animations", title: "Panel animations", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b090b47fba5b..3aa44ddae00c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -244,6 +244,25 @@ describe("ClientSettings diff colors", () => { }); }); +describe("ClientSettings project monogram color", () => { + it("keeps automatic hash colors for existing settings without a saved choice", () => { + expect(decodeClientSettings({}).projectMonogramColor).toBe("auto"); + }); + + it.each(["auto", "accent"])("round-trips the %s choice", (projectMonogramColor) => { + const settings = decodeClientSettings({ projectMonogramColor }); + expect(encodeClientSettings(settings).projectMonogramColor).toBe(projectMonogramColor); + expect(decodeClientSettingsPatch({ projectMonogramColor }).projectMonogramColor).toBe( + projectMonogramColor, + ); + }); + + it("rejects unsupported choices", () => { + expect(() => decodeClientSettings({ projectMonogramColor: "rainbow" })).toThrow(); + expect(() => decodeClientSettingsPatch({ projectMonogramColor: "rainbow" })).toThrow(); + }); +}); + describe("ClientSettings load balancing", () => { it("requires opt-in when settings are new or omit load balancing", () => { expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7b3715d704be..bf9968dbd0ca 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -287,6 +287,9 @@ export const LoadBalancingWeights = Schema.Record( export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); +export const ProjectMonogramColor = Schema.Literals(["auto", "accent"]); +export type ProjectMonogramColor = typeof ProjectMonogramColor.Type; + export const ClientSettingsSchema = Schema.Struct({ notificationMode: NotificationMode.pipe( Schema.withDecodingDefault(Effect.succeed("off" as const)), @@ -295,6 +298,9 @@ export const ClientSettingsSchema = Schema.Struct({ diffColorScheme: DiffColorScheme.pipe( Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), + projectMonogramColor: ProjectMonogramColor.pipe( + Schema.withDecodingDefault(Effect.succeed("auto" as const)), + ), loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( @@ -1441,6 +1447,7 @@ export const ClientSettingsPatch = Schema.Struct({ notificationMode: Schema.optionalKey(NotificationMode), inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diffColorScheme: Schema.optionalKey(DiffColorScheme), + projectMonogramColor: Schema.optionalKey(ProjectMonogramColor), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast),