From c3aab2372f7ed3889eece2b3002db30c3ef19075 Mon Sep 17 00:00:00 2001 From: Darren Bryant Date: Mon, 14 Sep 2026 23:15:03 +0100 Subject: [PATCH 1/2] feat(web): add project monogram color setting Project monograms had one look: per-project hash colors. Users who want a calmer sidebar had no way to unify them. Adds an Appearance option, Project monogram color, with Automatic (existing hash colors) and Theme action (every monogram uses the theme action color). Defaults to Automatic so existing settings keep today's colors. Follows up on #11572 as a small QOL option. --- .../src/components/ProjectFavicon.test.tsx | 86 +++++++++++++++++++ apps/web/src/components/ProjectFavicon.tsx | 6 +- .../components/settings/SettingsPanels.tsx | 46 ++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++ apps/web/src/projectIdentity.test.ts | 23 +++++ apps/web/src/projectIdentity.ts | 17 +++- packages/contracts/src/settings.test.ts | 19 ++++ packages/contracts/src/settings.ts | 7 ++ 8 files changed, 206 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index b60cf3ae62c4..97b8aae9505b 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -6,6 +6,7 @@ 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" as string, })); const hooks = vi.hoisted(() => { @@ -65,6 +66,12 @@ 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"; @@ -116,10 +123,37 @@ function renderImage( return Component(props); } +type MonogramSvgElement = ReactElement<{ + readonly style?: { readonly backgroundColor?: string; readonly backgroundImage?: string }; + readonly children: [ + ReactElement<{ readonly fill?: string; readonly children?: unknown }>, + ReactElement, + ]; +}>; + +function renderMissingImageMonogram(title: string): MonogramSvgElement { + testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; + hooks.beginRender(); + const fallbackElement = ProjectFavicon({ + project: makeProject({ workspaceRoot: "/workspace/monogram", title }), + }) as ReactElement<{ + readonly projectName?: string; + readonly className?: string; + readonly icon: ComponentType<{ className?: string }>; + }>; + const Fallback = fallbackElement.type as ( + props: typeof fallbackElement.props, + ) => MonogramSvgElement; + const svg = Fallback(fallbackElement.props); + hooks.reset(); + return svg; +} + describe("ProjectFavicon", () => { beforeEach(() => { hooks.reset(); 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", () => { @@ -216,4 +250,56 @@ describe("ProjectFavicon", () => { faviconPath: "brand/icon.svg", }); }); + + it("uses hash colors and a white glyph for missing favicons in automatic mode", () => { + testState.projectMonogramColor = "auto"; + const svg = renderMissingImageMonogram("analytics-db"); + + expect(svg.props.style?.backgroundColor).toContain("hsl("); + expect(svg.props.children[0].props.fill).toBe("white"); + }); + + it("uses the theme action color for missing favicons in accent mode", () => { + testState.projectMonogramColor = "accent"; + const svg = renderMissingImageMonogram("analytics-db"); + + expect(svg.props.style?.backgroundColor).toBe("var(--primary)"); + expect(svg.props.style?.backgroundImage).toContain("var(--primary)"); + expect(svg.props.children[0].props.fill).toBe("var(--primary-foreground)"); + }); + + it("keeps the same monogram glyph in both color modes", () => { + testState.projectMonogramColor = "auto"; + const automatic = renderMissingImageMonogram("analytics-db"); + testState.projectMonogramColor = "accent"; + const accent = renderMissingImageMonogram("analytics-db"); + + expect(accent.props.children[0].props.children).toBe( + automatic.props.children[0].props.children, + ); + }); + + it("uses the theme action color for failed favicons in accent mode", () => { + testState.projectMonogramColor = "accent"; + const { Component, props } = resolveImageComponent(); + renderImage(Component, props).props.children[2]?.props.onLoad?.(); + renderImage(Component, props).props.children[1]?.props.onError?.(); + + const afterError = renderImage(Component, props).props.children; + const fallbackElement = afterError[0] as ReactElement<{ + readonly projectName?: string; + readonly className?: string; + readonly icon: ComponentType<{ className?: string }>; + }>; + expect(fallbackElement).not.toBeNull(); + hooks.beginRender(); + const Fallback = fallbackElement.type as ( + props: typeof fallbackElement.props, + ) => MonogramSvgElement; + const svg = Fallback(fallbackElement.props); + hooks.reset(); + + expect(svg.props.style?.backgroundColor).toBe("var(--primary)"); + expect(svg.props.children[0].props.fill).toBe("var(--primary-foreground)"); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index c545fb188880..5bad3437fd99 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 { cn } from "~/lib/utils"; @@ -110,8 +111,9 @@ 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); + const identity = deriveProjectIdentity(projectName, projectMonogramColor); return (
+ +
+ } + /> 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/apps/web/src/projectIdentity.test.ts b/apps/web/src/projectIdentity.test.ts index 942e76fc91b8..0068c9700bd2 100644 --- a/apps/web/src/projectIdentity.test.ts +++ b/apps/web/src/projectIdentity.test.ts @@ -31,4 +31,27 @@ describe("deriveProjectIdentity", () => { expect(colors.size).toBeGreaterThan(1); }); + + it("preserves hash colors when the color is omitted or automatic", () => { + const omitted = deriveProjectIdentity("Nebula"); + const automatic = deriveProjectIdentity("Nebula", "auto"); + + expect(automatic.background).toBe(omitted.background); + expect(automatic.highlight).toBe(omitted.highlight); + expect(automatic.monogram).toBe(omitted.monogram); + }); + + it("uses the theme action color for both backgrounds in accent mode", () => { + const identity = deriveProjectIdentity("Nebula", "accent"); + + expect(identity.background).toBe("var(--primary)"); + expect(identity.highlight).toBe("var(--primary)"); + }); + + it("keeps the monogram glyph unchanged in accent mode", () => { + expect(deriveProjectIdentity("Nebula", "accent").monogram).toBe( + deriveProjectIdentity("Nebula").monogram, + ); + expect(deriveProjectIdentity("m7forge", "accent").monogram).toBe("M7"); + }); }); diff --git a/apps/web/src/projectIdentity.ts b/apps/web/src/projectIdentity.ts index 661a07553a65..a4ba9487b09e 100644 --- a/apps/web/src/projectIdentity.ts +++ b/apps/web/src/projectIdentity.ts @@ -1,3 +1,5 @@ +import type { ProjectMonogramColor } from "@t3tools/contracts/settings"; + /** Visual identity tokens for a generated project badge. */ export interface ProjectIdentity { readonly monogram: string; @@ -33,10 +35,21 @@ function projectHue(projectName: string): number { } /** Derives the stable monogram and generated colors used when a project has no icon. */ -export function deriveProjectIdentity(projectName: string): ProjectIdentity { +export function deriveProjectIdentity( + projectName: string, + color?: ProjectMonogramColor, +): ProjectIdentity { + const monogram = projectMonogram(projectName); + if (color === "accent") { + return { + monogram, + background: "var(--primary)", + highlight: "var(--primary)", + }; + } const hue = projectHue(projectName); return { - monogram: projectMonogram(projectName), + monogram, background: `hsl(${hue} 48% 36%)`, highlight: `hsl(${(hue + 24) % 360} 58% 48%)`, }; 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), From 4dbedc749d91cd91199107dd22c9a72cda1f569c Mon Sep 17 00:00:00 2001 From: Darren Bryant Date: Tue, 15 Sep 2026 00:00:01 +0100 Subject: [PATCH 2/2] test(web): render project favicon tests through React --- .../src/components/ProjectFavicon.test.tsx | 347 +++++++----------- 1 file changed, 140 insertions(+), 207 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 97b8aae9505b..e0e1af194160 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -1,61 +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" as string, + 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", () => ({ @@ -75,6 +33,8 @@ vi.mock("../hooks/useSettings", () => ({ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; +let renderer: ReactTestRenderer | undefined; + function makeProject( overrides: Partial & Pick, @@ -82,167 +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(); - - return { - Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, - props: element.props, - }; -} - -function renderImage( - Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, - props: ProjectFaviconImageProps, -): ProjectFaviconImageElement { - hooks.beginRender(); - return Component(props); +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; } -type MonogramSvgElement = ReactElement<{ - readonly style?: { readonly backgroundColor?: string; readonly backgroundImage?: string }; - readonly children: [ - ReactElement<{ readonly fill?: string; readonly children?: unknown }>, - ReactElement, - ]; -}>; - -function renderMissingImageMonogram(title: string): MonogramSvgElement { +async function renderMissingImageMonogram(title: string) { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; - hooks.beginRender(); - const fallbackElement = ProjectFavicon({ - project: makeProject({ workspaceRoot: "/workspace/monogram", title }), - }) as ReactElement<{ - readonly projectName?: string; - readonly className?: string; - readonly icon: ComponentType<{ className?: string }>; - }>; - const Fallback = fallbackElement.type as ( - props: typeof fallbackElement.props, - ) => MonogramSvgElement; - const svg = Fallback(fallbackElement.props); - hooks.reset(); - return svg; + const rendered = await renderProject( + makeProject({ workspaceRoot: "/workspace/monogram", title }), + ); + const svg = rendered.root.findByType("svg"); + const text = rendered.root.findByType("text"); + return { + backgroundColor: svg.props.style?.backgroundColor, + backgroundImage: svg.props.style?.backgroundImage, + fill: text.props.fill, + glyph: text.children.join(""), + }; } 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", @@ -251,55 +188,51 @@ describe("ProjectFavicon", () => { }); }); - it("uses hash colors and a white glyph for missing favicons in automatic mode", () => { + it("uses hash colors and a white glyph for missing favicons in automatic mode", async () => { testState.projectMonogramColor = "auto"; - const svg = renderMissingImageMonogram("analytics-db"); + const monogram = await renderMissingImageMonogram("analytics-db"); - expect(svg.props.style?.backgroundColor).toContain("hsl("); - expect(svg.props.children[0].props.fill).toBe("white"); + expect(monogram.backgroundColor).toContain("hsl("); + expect(monogram.fill).toBe("white"); }); - it("uses the theme action color for missing favicons in accent mode", () => { + it("uses the theme action color for missing favicons in accent mode", async () => { testState.projectMonogramColor = "accent"; - const svg = renderMissingImageMonogram("analytics-db"); + const monogram = await renderMissingImageMonogram("analytics-db"); - expect(svg.props.style?.backgroundColor).toBe("var(--primary)"); - expect(svg.props.style?.backgroundImage).toContain("var(--primary)"); - expect(svg.props.children[0].props.fill).toBe("var(--primary-foreground)"); + expect(monogram.backgroundColor).toBe("var(--primary)"); + expect(monogram.backgroundImage).toContain("var(--primary)"); + expect(monogram.fill).toBe("var(--primary-foreground)"); }); - it("keeps the same monogram glyph in both color modes", () => { + it("keeps the same monogram glyph in both color modes", async () => { testState.projectMonogramColor = "auto"; - const automatic = renderMissingImageMonogram("analytics-db"); + const automatic = await renderMissingImageMonogram("analytics-db"); testState.projectMonogramColor = "accent"; - const accent = renderMissingImageMonogram("analytics-db"); + const accent = await renderMissingImageMonogram("analytics-db"); - expect(accent.props.children[0].props.children).toBe( - automatic.props.children[0].props.children, - ); + expect(accent.glyph).toBe(automatic.glyph); }); - it("uses the theme action color for failed favicons in accent mode", () => { + it("uses the theme action color for failed favicons in accent mode", async () => { testState.projectMonogramColor = "accent"; - const { Component, props } = resolveImageComponent(); - renderImage(Component, props).props.children[2]?.props.onLoad?.(); - renderImage(Component, props).props.children[1]?.props.onError?.(); - - const afterError = renderImage(Component, props).props.children; - const fallbackElement = afterError[0] as ReactElement<{ - readonly projectName?: string; - readonly className?: string; - readonly icon: ComponentType<{ className?: string }>; - }>; - expect(fallbackElement).not.toBeNull(); - hooks.beginRender(); - const Fallback = fallbackElement.type as ( - props: typeof fallbackElement.props, - ) => MonogramSvgElement; - const svg = Fallback(fallbackElement.props); - hooks.reset(); - - expect(svg.props.style?.backgroundColor).toBe("var(--primary)"); - expect(svg.props.children[0].props.fill).toBe("var(--primary-foreground)"); + 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)"); }); });