diff --git a/src/components/insights/__tests__/target-adjust-button.test.tsx b/src/components/insights/__tests__/target-adjust-button.test.tsx new file mode 100644 index 000000000..758eb1cce --- /dev/null +++ b/src/components/insights/__tests__/target-adjust-button.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * `` 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 `` (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( + {node}, + ); +} + +describe("", () => { + it("renders nothing when no provider is mounted", () => { + useTargetAdjustMock.mockReturnValue(null); + const html = render(); + 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(); + 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(); + 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(); + // 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(); + // 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"); + }); +}); diff --git a/src/components/insights/target-adjust-button.tsx b/src/components/insights/target-adjust-button.tsx index b0a80d3ad..bcb8df982 100644 --- a/src/components/insights/target-adjust-button.tsx +++ b/src/components/insights/target-adjust-button.tsx @@ -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, )} >