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:
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user