diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx
index 6aff1bad6..938b901b2 100644
--- a/src/components/charts/compliance-heatmap.tsx
+++ b/src/components/charts/compliance-heatmap.tsx
@@ -20,6 +20,10 @@ interface ComplianceHeatmapProps {
const CELL_SIZE = 18;
const GAP = 3;
+// v1.4.27 MB7 / CF-10 — cell-floor so the static heatmap never paints
+// below the touch-friendly 14 px square on narrow viewports. The
+// stretch branch computes an adaptive size; both branches clamp here.
+const CELL_FLOOR_PX = 14;
function getColor(data: DailyData): string {
if (data.expected === 0) return "var(--secondary)";
@@ -78,8 +82,36 @@ export function ComplianceHeatmap({
x: number;
y: number;
text: string;
+ /**
+ * v1.4.27 MB7 / CF-10 — when `pinned` the tooltip stays mounted
+ * across `onPointerLeave` so a touch user sees the per-cell
+ * breakdown after lifting their finger. Mouse + pen users get the
+ * existing hover-only experience because their interactions never
+ * set `pinned`. A second tap on a different cell repositions the
+ * tooltip; a tap outside any cell clears it (wired below).
+ */
+ pinned?: boolean;
} | null>(null);
+ // v1.4.27 MB7 / CF-10 — outside-click dismisses a pinned tooltip so a
+ // touch user can clear the per-cell detail without scrolling the
+ // pinned label off-screen. The listener is gated on `tooltip?.pinned`
+ // so the non-touch hover flow never pays the indirection cost.
+ useEffect(() => {
+ if (!tooltip?.pinned) return;
+ const handlePointer = (event: PointerEvent) => {
+ const container = containerRef.current;
+ if (!container) return;
+ if (!container.contains(event.target as Node)) {
+ setTooltip(null);
+ }
+ };
+ document.addEventListener("pointerdown", handlePointer, true);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointer, true);
+ };
+ }, [tooltip?.pinned]);
+
const WEEKDAY_LABELS = [
t("charts.weekdays.mon"),
"",
@@ -188,10 +220,13 @@ export function ComplianceHeatmap({
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
+ // v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the
+ // 14 px floor so the heatmap stays tap-friendly when the container
+ // narrows. Static (non-stretch) instances use the canonical 18 px.
const cellSize =
stretch && containerWidth > 0
? Math.max(
- 8,
+ CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
)
@@ -202,12 +237,20 @@ export function ComplianceHeatmap({
return (
-
+ {/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on `=sm`, so the scroll branch
+ only kicks in for the non-stretch (static 18 px cell) case
+ when the parent column is narrower than `weeks * 21 px`. */}
+