fix(frontend): expand empty-state watermark to full cell and tint monochrome project icons by project color — #351 (QA verte)

Cause: the empty-state watermark filled only 92% of a centered box and
kept residual palette colors from host emoji fonts, non-compliant with
the monochrome-first project icon rule (#351 user capture).
Fix: illustration spans the cell with 6px/4px insets and an unclipped
glyph at min(88cqw, 88cqh); emoji painted as monochrome silhouettes via
transparent fill + zero-offset text-shadow tint
(--project-illustration-tint) derived from accessibleProjectAccent when
a project color exists, neutral muted fallback otherwise; empty label
anchored bottom-center and hidden first on constrained containers
(@container 280px width / 240px height); overlay priorities, composer
and z-layering untouched.
QA verte reconfirmée par Git: npm test -- CustomAgentChatView.test.tsx
80/80, git diff --check; QA: ProjectSettingsPanel 12/12, typecheck,
build OK (warning Vite chunk >500 kB préexistant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 19:04:32 +02:00
parent 64da043a8e
commit 08c05c1841
3 changed files with 67 additions and 12 deletions

View File

@ -212,16 +212,19 @@ describe("CustomAgentChatView", () => {
const watermark = await screen.findByTestId("project-icon-watermark"); const watermark = await screen.findByTestId("project-icon-watermark");
expect(watermark.style.pointerEvents).toBe("none"); expect(watermark.style.pointerEvents).toBe("none");
expect(watermark.getAttribute("aria-hidden")).toBe("true"); expect(watermark.getAttribute("aria-hidden")).toBe("true");
expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%"); expect(watermark.className).toContain("project-cell-illustration");
expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%"); 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)?.className).toContain("project-cell-illustration__glyph");
expect(watermark.textContent).toBe("🐍\uFE0E"); expect(watermark.textContent).toBe("🐍\uFE0E");
expect(watermark.dataset.colorMode).toBe("monochrome"); expect(watermark.dataset.colorMode).toBe("monochrome");
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("⭐\uFE0E"));
publishProjectThemeChanged({ projectId: "project-1", themeColor: "#7c3aed" }); publishProjectThemeChanged({ projectId: "project-1", themeColor: "#7c3aed" });
await waitFor(() => { await waitFor(() => {
expect(watermark.dataset.colorMode).toBe("project"); expect(watermark.dataset.colorMode).toBe("project");
expect(watermark.style.color).toBe("rgb(124, 58, 237)"); 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,6 +15,7 @@ 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,
@ -45,7 +46,11 @@ 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 { onProjectThemeChanged, validThemeColor } from "@/features/projects/projectTheme"; import {
accessibleProjectAccent,
onProjectThemeChanged,
validThemeColor,
} from "@/features/projects/projectTheme";
import type { import type {
ChatAttachmentInput, ChatAttachmentInput,
ChatDraftScope, ChatDraftScope,
@ -2504,26 +2509,28 @@ export function CustomAgentChatView({
<div <div
ref={chatRootRef} ref={chatRootRef}
data-testid="custom-agent-chat-view" data-testid="custom-agent-chat-view"
className="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 <div
data-testid="project-icon-watermark" data-testid="project-icon-watermark"
data-color-mode={projectThemeColor ? "project" : "monochrome"} data-color-mode={projectThemeColor ? "project" : "monochrome"}
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.055]" className="project-cell-illustration pointer-events-none absolute z-0 flex items-center justify-center overflow-hidden"
style={{ style={{
pointerEvents: "none", pointerEvents: "none",
containerType: "size", "--project-illustration-tint": projectThemeColor
color: projectThemeColor ?? undefined, ? accessibleProjectAccent(projectThemeColor)
}} : "var(--color-muted, #9aa7b4)",
} as CSSProperties}
> >
<ProjectIcon <ProjectIcon
icon={projectIcon} icon={projectIcon}
fallbackLabel={projectFallbackLabel} fallbackLabel={projectFallbackLabel}
surface="watermark" surface="watermark"
size="92%" size="100%"
decorative decorative
style={{ fontSize: "min(34cqw, 34cqh)", maxWidth: "92cqh", maxHeight: "92cqw" }} className="project-cell-illustration__glyph"
style={{ fontSize: "min(88cqw, 88cqh)" }}
/> />
</div> </div>
<div <div
@ -2727,7 +2734,7 @@ export function CustomAgentChatView({
</div> </div>
</div> </div>
) : displayTurns.length === 0 ? ( ) : displayTurns.length === 0 ? (
<p className="m-auto text-xs text-muted"> <p className="project-cell-empty-label text-xs text-muted">
{t("custom.empty")} {t("custom.empty")}
</p> </p>
) : ( ) : (

View File

@ -103,6 +103,51 @@
} }
@layer components { @layer components {
.custom-agent-chat-view {
container-type: size;
}
.project-cell-illustration {
inset: 6px 4px;
color: transparent;
opacity: 0.045;
}
.project-cell-illustration[data-color-mode="project"] {
opacity: 0.065;
}
/* 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 {
color: transparent;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 0 var(--project-illustration-tint, var(--color-muted));
}
.project-cell-empty-label {
position: relative;
z-index: 1;
max-width: min(34rem, 88%);
margin: auto auto 8px;
text-align: center;
}
/* Preserve the illustration when a split cell becomes constrained; the
explanatory copy is the first element to yield. */
@container (max-width: 280px) {
.project-cell-empty-label {
display: none;
}
}
@container (max-height: 240px) {
.project-cell-empty-label {
display: none;
}
}
.stopped-agent-cell { .stopped-agent-cell {
position: absolute; position: absolute;
inset: 0; inset: 0;