merge(frontend): fix #353 — icônes projet watermark plein espace et niveaux de gris préservant les détails internes (QA verte)

This commit is contained in:
2026-09-14 07:31:56 +02:00
5 changed files with 102 additions and 135 deletions

View File

@ -12,7 +12,6 @@ import {
hasProjectCliInteraction, hasProjectCliInteraction,
} from "@/features/projects/projectCliInteractionActivity"; } from "@/features/projects/projectCliInteractionActivity";
import { publishProjectIconChanged } from "@/features/projects/projectIcons"; import { publishProjectIconChanged } from "@/features/projects/projectIcons";
import { publishProjectThemeChanged } from "@/features/projects/projectTheme";
import { import {
PluginCommandRegistry, PluginCommandRegistry,
PluginLayoutRegistry, PluginLayoutRegistry,
@ -217,16 +216,10 @@ describe("CustomAgentChatView", () => {
expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("100%"); expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("100%");
expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("100%"); expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("100%");
expect((watermark.firstElementChild as HTMLElement | null)?.className).toContain("project-cell-illustration__glyph"); expect((watermark.firstElementChild as HTMLElement | null)?.className).toContain("project-cell-illustration__glyph");
expect(watermark.textContent).toBe("🐍\uFE0E"); expect(watermark.textContent).toBe("🐍");
expect(watermark.dataset.colorMode).toBe("monochrome"); expect(watermark.parentElement?.className).toContain("project-cell-illustration-area");
expect(watermark.style.getPropertyValue("--project-illustration-tint")).toBe("var(--color-muted, #9aa7b4)");
publishProjectIconChanged({ projectId: "project-1", icon: { set: "emoji", name: "star" } }); publishProjectIconChanged({ projectId: "project-1", icon: { set: "emoji", name: "star" } });
await waitFor(() => expect(watermark.textContent).toBe("⭐\uFE0E")); await waitFor(() => expect(watermark.textContent).toBe("⭐"));
publishProjectThemeChanged({ projectId: "project-1", themeColor: "#7c3aed" });
await waitFor(() => {
expect(watermark.dataset.colorMode).toBe("project");
expect(watermark.style.getPropertyValue("--project-illustration-tint")).toBe("#945DF0");
});
expect(screen.getByTestId("custom-agent-chat-scroll").className).toContain("z-10"); expect(screen.getByTestId("custom-agent-chat-scroll").className).toContain("z-10");
expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40"); expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40");
}); });

View File

@ -15,7 +15,6 @@ import {
useRef, useRef,
useState, useState,
type ClipboardEvent, type ClipboardEvent,
type CSSProperties,
type KeyboardEvent, type KeyboardEvent,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
type RefObject, type RefObject,
@ -46,11 +45,6 @@ import {
} from "@/features/projects/projectCliInteractionActivity"; } from "@/features/projects/projectCliInteractionActivity";
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared"; import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons"; import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
import {
accessibleProjectAccent,
onProjectThemeChanged,
validThemeColor,
} from "@/features/projects/projectTheme";
import type { import type {
ChatAttachmentInput, ChatAttachmentInput,
ChatDraftScope, ChatDraftScope,
@ -1077,7 +1071,6 @@ export function CustomAgentChatView({
const [stopTerminalOverride, setStopTerminalOverride] = useState(false); const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null); const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null); const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const [projectThemeColor, setProjectThemeColor] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null); const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null); const chatRootRef = useRef<HTMLDivElement | null>(null);
@ -1135,21 +1128,14 @@ export function CustomAgentChatView({
void project?.readProjectSettings(projectId).then((settings) => { void project?.readProjectSettings(projectId).then((settings) => {
if (!cancelled) { if (!cancelled) {
setProjectIcon(settings.icon ?? null); setProjectIcon(settings.icon ?? null);
setProjectThemeColor(validThemeColor(settings.themeColor));
} }
}).catch(() => undefined); }).catch(() => undefined);
const unsubscribeIcon = onProjectIconChanged((change) => { const unsubscribeIcon = onProjectIconChanged((change) => {
if (change.projectId === projectId) setProjectIcon(change.icon); if (change.projectId === projectId) setProjectIcon(change.icon);
}); });
const unsubscribeTheme = onProjectThemeChanged((change) => {
if (change.projectId === projectId) {
setProjectThemeColor(validThemeColor(change.themeColor));
}
});
return () => { return () => {
cancelled = true; cancelled = true;
unsubscribeIcon(); unsubscribeIcon();
unsubscribeTheme();
}; };
}, [project, projectId]); }, [project, projectId]);
@ -2789,28 +2775,6 @@ export function CustomAgentChatView({
data-testid="custom-agent-chat-view" data-testid="custom-agent-chat-view"
className="custom-agent-chat-view relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content" className="custom-agent-chat-view relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
> >
<div
data-testid="project-icon-watermark"
data-color-mode={projectThemeColor ? "project" : "monochrome"}
aria-hidden="true"
className="project-cell-illustration pointer-events-none absolute z-0 flex items-center justify-center overflow-hidden"
style={{
pointerEvents: "none",
"--project-illustration-tint": projectThemeColor
? accessibleProjectAccent(projectThemeColor)
: "var(--color-muted, #9aa7b4)",
} as CSSProperties}
>
<ProjectIcon
icon={projectIcon}
fallbackLabel={projectFallbackLabel}
surface="watermark"
size="100%"
decorative
className="project-cell-illustration__glyph"
style={{ fontSize: "min(88cqw, 88cqh)" }}
/>
</div>
<div <div
role="toolbar" role="toolbar"
aria-label={t("custom.actions")} aria-label={t("custom.actions")}
@ -2978,76 +2942,93 @@ export function CustomAgentChatView({
</div> </div>
)} )}
<div <div className="project-cell-illustration-area relative z-10 flex min-h-0 flex-1 basis-0 overflow-hidden">
ref={scrollRef} <div
data-testid="custom-agent-chat-scroll" data-testid="project-icon-watermark"
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" aria-hidden="true"
onScroll={updateLatestVisibility} className="project-cell-illustration pointer-events-none absolute z-0 flex items-center justify-center overflow-hidden"
> style={{ pointerEvents: "none" }}
<div className="shrink-0"> >
<AnnouncementsPreview <ProjectIcon
requester={agentId} icon={projectIcon}
fallbackActive={delegationActive} fallbackLabel={projectFallbackLabel}
fallbackText={delegationFallbackText} surface="watermark"
size="100%"
decorative
className="project-cell-illustration__glyph"
/> />
</div> </div>
{opening && displayTurns.length === 0 ? ( <div
<div className="flex items-center gap-2 text-sm text-muted"> ref={scrollRef}
<Spinner size={14} /> data-testid="custom-agent-chat-scroll"
<span> className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
{externalSessionId ? t("custom.resume") : t("custom.opening")} onScroll={updateLatestVisibility}
</span> >
</div> <div className="shrink-0">
) : unavailableLinkedTranscript ? ( <AnnouncementsPreview
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center"> requester={agentId}
<p className="text-sm font-medium text-content"> fallbackActive={delegationActive}
{tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")} fallbackText={delegationFallbackText}
</p>
<p className="text-xs text-muted">
{unavailableLinkedTranscript === "conversationNotFound"
? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.")
: tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={retryUnavailableTranscript}
>
{t("common.retry")}
</Button>
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
{tr("Délier la conversation", "Unlink conversation")}
</Button>
</div>
</div>
) : displayTurns.length === 0 ? (
<p className="project-cell-empty-label text-xs text-muted">
{t("custom.empty")}
</p>
) : (
displayTurns.map((turn, index) => (
<ChatBubble
key={index}
turn={turn}
requesterName={agentName}
sessionLabel={sessionLabel}
expandedProgressKeys={expandedProgressKeys}
onToggleProgressExpanded={(key) =>
setExpandedProgressKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
})
}
/> />
)) </div>
)} {opening && displayTurns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>
{externalSessionId ? t("custom.resume") : t("custom.opening")}
</span>
</div>
) : unavailableLinkedTranscript ? (
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
<p className="text-sm font-medium text-content">
{tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")}
</p>
<p className="text-xs text-muted">
{unavailableLinkedTranscript === "conversationNotFound"
? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.")
: tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={retryUnavailableTranscript}
>
{t("common.retry")}
</Button>
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
{tr("Délier la conversation", "Unlink conversation")}
</Button>
</div>
</div>
) : displayTurns.length === 0 ? (
<p className="project-cell-empty-label text-xs text-muted">
{t("custom.empty")}
</p>
) : (
displayTurns.map((turn, index) => (
<ChatBubble
key={index}
turn={turn}
requesterName={agentName}
sessionLabel={sessionLabel}
expandedProgressKeys={expandedProgressKeys}
onToggleProgressExpanded={(key) =>
setExpandedProgressKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
})
}
/>
))
)}
</div>
</div> </div>
<div <div

View File

@ -43,7 +43,7 @@ describe("project emoji catalogue", () => {
expect(getByRole("img").dataset.projectIcon).toBe("none"); expect(getByRole("img").dataset.projectIcon).toBe("none");
}); });
it("requests a monochrome text presentation for watermark emoji only", () => { it("keeps the full emoji artwork available for grayscale watermark rendering", () => {
const { getByTestId } = render( const { getByTestId } = render(
<> <>
<ProjectIcon icon={{ set: "emoji", name: "star" }} data-testid="product-icon" /> <ProjectIcon icon={{ set: "emoji", name: "star" }} data-testid="product-icon" />
@ -57,8 +57,8 @@ describe("project emoji catalogue", () => {
); );
expect(getByTestId("product-icon").textContent).toBe("⭐"); expect(getByTestId("product-icon").textContent).toBe("⭐");
expect(getByTestId("watermark-icon").textContent).toBe("⭐\uFE0E"); expect(getByTestId("watermark-icon").textContent).toBe("⭐");
expect((getByTestId("watermark-icon") as HTMLElement).style.fontFamily).toContain("system-ui"); expect((getByTestId("watermark-icon") as HTMLElement).style.fontFamily).toContain("Color Emoji");
}); });
}); });

View File

@ -210,9 +210,6 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
const label = entry?.label ?? fallbackInitial(ref, fallbackLabel); const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
const numericSize = typeof size === "number" ? size : null; const numericSize = typeof size === "number" ? size : null;
const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel); const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel);
const renderedGlyph = surface === "watermark" && entry
? `${glyph.replace(/\uFE0F/g, "\uFE0E")}${glyph.includes("\uFE0F") ? "" : "\uFE0E"}`
: glyph;
return ( return (
<span <span
{...htmlProps} {...htmlProps}
@ -226,13 +223,11 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
style={{ style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", display: "inline-flex", alignItems: "center", justifyContent: "center",
width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%", width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
fontFamily: surface === "watermark" fontFamily: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
? "ui-sans-serif, system-ui, sans-serif"
: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style, fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
}} }}
> >
{renderedGlyph} {glyph}
</span> </span>
); );
} }

View File

@ -107,23 +107,21 @@
container-type: size; container-type: size;
} }
.project-cell-illustration-area {
container-type: size;
}
.project-cell-illustration { .project-cell-illustration {
inset: 6px 4px; inset: 6px 4px;
color: transparent; opacity: 0.085;
opacity: 0.045;
} }
.project-cell-illustration[data-color-mode="project"] { /* Keep the operating system's full-colour emoji artwork as the source image:
opacity: 0.065; grayscale preserves internal tones and details that a monochrome alpha
} mask (for example color: transparent + text-shadow) would flatten. */
/* Emoji fonts may ignore the text-variation selector and retain their own
palette. Painting a zero-offset text shadow through the glyph alpha mask
guarantees one semantic project tint without depending on the host font. */
.project-cell-illustration__glyph { .project-cell-illustration__glyph {
color: transparent; filter: grayscale(1) contrast(0.92);
-webkit-text-fill-color: transparent; font-size: min(calc(100cqw - 8px), calc(100cqh - 12px)) !important;
text-shadow: 0 0 0 var(--project-illustration-tint, var(--color-muted));
} }
.project-cell-empty-label { .project-cell-empty-label {