fix(dashboard): equal-height tile contract on mobile

The dashboard tile strip enforces equal heights at `sm:` and above
via `grid auto-rows-fr` but lets each tile take its content height
at `<sm`. Sibling drift of 16-24 px appears whenever the comparison
callout or the sub-row pair paints on one tile and not on its
neighbour (the glucose tiles ship without `trend7Delta` /
`compareDelta`, making this the visible case).

Pin a 140-px contract at `<sm` and let it release back to `auto`
from `sm:` upwards:

- Strip wrapper carries `[--tile-h:140px] sm:[--tile-h:auto]`.
- Each tile cell consumes `h-[var(--tile-h)] sm:h-auto`.
- Inside `<TrendCard>`, the comparison-callout slot reserves
  `min-h-[18px]` even when empty and clamps to `line-clamp-1`
  at `<sm`.
- Sub-row pair switches to `flex-nowrap overflow-hidden` at `<sm`
  (clipping over wrapping) and releases to `flex-wrap` from `sm:`.

A new snapshot test pins the contract: callout slot reserves the
minimum height, callout clamps to a single line, sub-row pair
renders with `flex-nowrap` + `overflow-hidden`.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 10:44:29 +02:00
1 parent f463ca812c
commit d381f23e59
3 files changed
+89 -7

No files matched your search

+9 -1
View File
@@ -1211,6 +1211,14 @@ export default function DashboardPage() {
// branch above `sm:`.
className={cn(
"flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2",
// v1.4.29 — fixed mobile tile height contract.
// At `<sm` every tile pins to 140 px via the
// `--tile-h` CSS custom property so divergent
// content (callout on / off, sub-row wrap / no
// wrap) cannot drift sibling heights. `sm:`
// releases to `auto` where `grid auto-rows-fr`
// already enforces equal heights.
"[--tile-h:140px] sm:[--tile-h:auto]",
"sm:grid sm:snap-none sm:auto-rows-fr sm:overflow-visible",
"sm:[grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]",
)}
@@ -1221,7 +1229,7 @@ export default function DashboardPage() {
{trendCards.map((entry) => (
<div
key={entry.id}
className="flex min-w-[10rem] shrink-0 snap-start sm:min-w-0 sm:shrink"
className="flex h-[var(--tile-h)] min-w-[10rem] shrink-0 snap-start sm:h-auto sm:min-w-0 sm:shrink"
>
{entry.node}
</div>
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Activity } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.29 — mobile tile equal-height contract.
*
* On `<sm` viewports each dashboard tile pins to `--tile-h: 140px`
* via the strip wrapper. The tile-internal callout slot reserves
* `min-h-[18px]` even when no callout is rendered, and the sub-row
* pair switches to `flex-nowrap overflow-hidden` so a narrow tile
* cannot grow vertically.
*
* Per R-C §6 this contract pins regardless of which combination of
* `compareDelta` / `trend7Delta` / sub-rows the tile is rendering.
*/
const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 };
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<TrendCard> mobile tile equal-height contract", () => {
const baseProps = {
label: "Pulse",
latest: 72,
unit: "bpm",
avg7: 70,
avg30: 71,
icon: Activity,
slope30: RISING,
};
it("renders the callout slot with min-h-[18px] reserved", () => {
const html = render(<TrendCard {...baseProps} />);
// The callout slot wrapper sits between value row + sub-rows and
// reserves a fixed minimum height so sibling tiles don't drift
// when the callout is absent.
expect(html).toContain("min-h-[18px]");
});
it("clamps the comparison callout to one line at <sm", () => {
const html = render(
<TrendCard
{...baseProps}
compareBaseline="lastMonth"
compareDelta={1.2}
/>,
);
expect(html).toContain("line-clamp-1");
expect(html).toContain('data-slot="tile-compare-delta"');
});
it("renders the sub-row pair with flex-nowrap + overflow-hidden", () => {
const html = render(<TrendCard {...baseProps} />);
expect(html).toContain("flex-nowrap");
expect(html).toContain("overflow-hidden");
});
});
+15 -6
View File
@@ -265,11 +265,16 @@ export function TrendCard({
flex-wrap on the sibling row + this callout's `mt-1`
horizontal-row layout absorb the overflow without crowding
the headline value above. */}
{compareBaseline !== "none" && compareDelta != null && (
<div className="mt-1">
{/* v1.4.29 — clamp the callout to one line at `<sm` so the
tile's overall height stays inside the 140-px contract.
`min-h-[18px]` reserves the slot when no callout is being
rendered so the sub-row pair below pins to the same y
across every tile. `sm:` releases both bounds. */}
<div className="mt-1 min-h-[18px] sm:min-h-0">
{compareBaseline !== "none" && compareDelta != null && (
<span
className={cn(
"inline-block max-w-full text-xs leading-snug font-medium [overflow-wrap:anywhere] tabular-nums",
"line-clamp-1 inline-block max-w-full text-xs leading-snug font-medium tabular-nums sm:line-clamp-none sm:[overflow-wrap:anywhere]",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta"
@@ -286,10 +291,14 @@ export function TrendCard({
: "comparison.captionLastYear",
)}`}
</span>
</div>
)}
)}
</div>
<TooltipProvider>
<div className="text-muted-foreground mt-auto flex min-w-0 flex-wrap gap-x-3 gap-y-1 pt-1 text-xs leading-snug">
{/* v1.4.29 — sub-row pair clips rather than wraps at `<sm`
so a narrow tile cannot grow vertically beyond the
140-px contract. `sm:` releases back to the original
wrap behaviour. */}
<div className="text-muted-foreground mt-auto flex min-w-0 flex-nowrap items-baseline gap-x-3 overflow-hidden pt-1 text-xs leading-snug sm:flex-wrap sm:gap-y-1">
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t(avg7LabelKey)}:{" "}
{avg7Hint ? (