fix(insights): match the header target gear to the Coach icon size

The target gear painted a heavier 44 px box on mobile against the Coach
icon's flat 40 px, so the header cluster read as two mismatched icons.
Settle the gear on a flat 40 px box that equals the Coach sparkle on every
breakpoint, and restore the 44 px WCAG 2.5.5 touch target through a
transparent ::before overlay rather than a larger painted box. The shared
icon-button size stays untouched, so other icon affordances are unaffected.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:20:38 +02:00
1 parent 252921b563
commit 93bdbd8e0c
2 files changed
+104 -4

No files matched your search

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* `<TargetAdjustButton>` unit tests.
*
* The header gear opens the per-metric target editor. It self-gates on a
* registered editable target (`canAdjust`), so a metric with no numeric
* band shows nothing. The test convention is `renderToStaticMarkup` (no
* `@testing-library/react`), so `useTargetAdjust` is mocked to drive the
* gated / ungated branches and the click handler is exercised via the
* mocked `requestAdjust` reference.
*
* The load-bearing visual contract: the gear must read as the *same*
* size as the sibling `<CoachLaunchButton variant="icon">` (a flat 40 px
* `size-icon` box), with the WCAG 2.5.5 touch target restored through an
* invisible `::before` overlay rather than a larger painted box. The
* class assertions pin that the gear paints a flat `size-10` (40 px) and
* carries the transparent hit-area overlay, and never the old
* `size-11`/`sm:size-10` box that made it visually heavier than the
* Coach icon.
*/
const useTargetAdjustMock = vi.fn();
vi.mock("@/lib/insights/target-adjust-context", () => ({
useTargetAdjust: () => useTargetAdjustMock(),
}));
const { TargetAdjustButton } = await import("../target-adjust-button");
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<TargetAdjustButton>", () => {
it("renders nothing when no provider is mounted", () => {
useTargetAdjustMock.mockReturnValue(null);
const html = render(<TargetAdjustButton />);
expect(html).toBe("");
});
it("renders nothing when no editable target has registered", () => {
useTargetAdjustMock.mockReturnValue({
canAdjust: false,
requestAdjust: vi.fn(),
register: vi.fn(),
});
const html = render(<TargetAdjustButton />);
expect(html).toBe("");
});
it("renders the gear once an editable target is registered", () => {
useTargetAdjustMock.mockReturnValue({
canAdjust: true,
requestAdjust: vi.fn(),
register: vi.fn(),
});
const html = render(<TargetAdjustButton />);
expect(html).toContain('data-slot="target-adjust-trigger"');
// The accessible label reuses the existing adjust-target copy.
expect(html).toContain('aria-label="Adjust target range"');
});
it("paints a flat 40px box matching the Coach icon, not the old heavier size-11", () => {
useTargetAdjustMock.mockReturnValue({
canAdjust: true,
requestAdjust: vi.fn(),
register: vi.fn(),
});
const html = render(<TargetAdjustButton />);
// Flat 40 px visual box, identical to the sibling Coach sparkle icon.
expect(html).toContain("size-10");
// The pre-parity heavier mobile box is gone.
expect(html).not.toContain("size-11");
});
it("restores the touch target with an invisible hit-area overlay rather than a bigger box", () => {
useTargetAdjustMock.mockReturnValue({
canAdjust: true,
requestAdjust: vi.fn(),
register: vi.fn(),
});
const html = render(<TargetAdjustButton />);
// A transparent `::before` overlay extends the hit area past the 40 px
// visual box to clear the WCAG 2.5.5 floor without enlarging the paint.
expect(html).toContain("before:absolute");
expect(html).toContain("before:-inset-1.5");
});
});
@@ -48,10 +48,17 @@ export function TargetAdjustButton({ className }: { className?: string }) {
title={label}
onClick={() => adjust.requestAdjust()}
className={cn(
// The `icon` size is 40 px; bump to the 44 px WCAG 2.5.5 floor on
// mobile and settle back to the 40 px that matches the sibling
// Coach icon from `sm` up, so the cluster reads as one on desktop.
"text-muted-foreground hover:text-foreground size-11 sm:size-10",
// The `icon` size is a flat 40 px — identical to the sibling Coach
// sparkle icon — so the gear + Coach read as one even-weighted icon
// cluster top-right of the heading on every breakpoint.
//
// The 40 px visual box is below the WCAG 2.5.5 (44 px) floor, so a
// transparent `::before` overlay extends the *hit* area by 6 px on
// every side (40 + 2×6 = 52 px) without enlarging the painted box.
// This keeps the touch target comfortable while the gear stays
// visually equal to the Coach icon.
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
className,
)}
>